Add the post-quantum certificate anchor for QBFT

This is the code that puts a post-quantum validator certificate under the block
hash. It is the thing this project exists to do, and it is published so that the
claim can be checked rather than believed.

What it is. In QBFT the block hash is computed over a re-encoding of the decoded
extraData with the seals removed, so anything the decoder does not know about is
dropped before hashing. Appending a certificate as a new element gives you a
certificate that is stored, gossiped, and entirely absent from the hash. The
design that works instead puts a 32-byte digest of the certificate into
vanityData, which is already under keccak. anchor/README.md sets out the four
designs that died before this one and why.

Scope, stated in the README and repeated here because it matters: consensus on
chain 2800 is classical secp256k1 ECDSA. This binds a post-quantum certificate to
the block hash. It does not make consensus post-quantum and is never described as
such.

What is here: the anchor, the validation rules, the wiring, and the tests,
including the negative controls. Applied to upstream d2032017bb, the pinned base
named in anchor/BASE.txt. One build file changes, by one line, and the README says
which and why. No cryptography is implemented here; Falcon verification calls
Bouncy Castle.

What is not here: no keys, no fleet configuration, and nothing about what is armed
on any running network.

Measured before publishing, on upstream d2032017bb with this overlay applied:
consensus:common and consensus:qbft, 605 tests, 0 failures, identical to the same
tree before this work, class by class.

Three things were found while preparing it, and all three are fixed here:
- the code spoke Romanian in 134 comment lines and 43 strings, 37 of them on
  production paths, which is to say in the messages a node prints when it refuses
  to start. An auditor given the code to check the guards could not read the
  guards.
- ten test classes carried internal issue numbers in their names. They now say
  what they test.
- the suite was green partly by ordering luck. One class cleared its system
  properties but not the configuration PqAnchorProducer remembers, so it left the
  anchor armed for whichever class ran next. Renaming the classes changed the
  order and four tests began failing on a guard that was firing correctly. Fixed
  where it leaks, with the negative control measured: remove the line and the
  pair goes red, restore it and it goes green.
This commit is contained in:
Liviu 2026-08-12 01:43:58 +03:00
parent e8deaa60b6
commit 56a02656aa
77 changed files with 31044 additions and 0 deletions

16
anchor/BASE.txt Normal file
View File

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

142
anchor/README.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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.
*
* <p><b>The requirement, in one sentence: there has to be a way back that does not need a build.</b>
* 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.
*
* <p><b>The three controls, and why exactly these three.</b>
*
* <ul>
* <li>{@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.
* <li>{@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.
* <li>{@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.
* </ul>
*
* <p><b>Every one of them shouts.</b> 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.
*
* <p><b>How they take effect, and why through the properties.</b> 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.
*
* <p><b>Deliberately LOCAL, not on-chain.</b> 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 = "<true|false>",
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 = "<integer>",
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 = "<true|false>",
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.
*
* <p>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.
*
* <p>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<String> 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;
}
}

View File

@ -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<QbftConfigOptions> 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<SnapProtocolManager> 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<QbftMinedBlockObserver> 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<QbftMessage> 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<QbftFork> bftForks) {
final Map<Long, List<Address>> 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.
*
* <p>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.
*
* <p>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.
*
* <p>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()));
}
}

View File

@ -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.
*
* <p>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);
}
}

View File

@ -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<String, String> 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<String, String> configOverrides) {
final TransitionsConfigOptions transitionsConfigOptions;
transitionsConfigOptions = loadTransitionsFrom(configRoot);
return new JsonGenesisConfigOptions(configRoot, configOverrides, transitionsConfigOptions);
}
private static TransitionsConfigOptions loadTransitionsFrom(final ObjectNode parentNode) {
final Optional<ObjectNode> 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<String, String> 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<BlobScheduleOptions> 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<Wei> getBaseFeePerGas() {
return Optional.ofNullable(configOverrides.get("baseFeePerGas")).map(Wei::fromHexString);
}
@Override
public Optional<UInt256> getTerminalTotalDifficulty() {
return getOptionalBigInteger("terminaltotaldifficulty").map(UInt256::valueOf);
}
@Override
public OptionalLong getTerminalBlockNumber() {
return getOptionalLong("terminalblocknumber");
}
@Override
public Optional<Hash> getTerminalBlockHash() {
return getOptionalHash("terminalblockhash");
}
@Override
public Optional<BigInteger> 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<String> 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<Address> getWithdrawalRequestContractAddress() {
Optional<String> inputAddress =
JsonUtil.getString(configRoot, WITHDRAWAL_REQUEST_CONTRACT_ADDRESS_KEY);
return inputAddress.map(Address::fromHexString);
}
@Override
public Optional<Address> getDepositContractAddress() {
Optional<String> inputAddress = JsonUtil.getString(configRoot, DEPOSIT_CONTRACT_ADDRESS_KEY);
return inputAddress.map(Address::fromHexString);
}
@Override
public Optional<Address> getConsolidationRequestContractAddress() {
Optional<String> inputAddress =
JsonUtil.getString(configRoot, CONSOLIDATION_REQUEST_CONTRACT_ADDRESS_KEY);
return inputAddress.map(Address::fromHexString);
}
@Override
public Map<String, Object> asMap() {
final ImmutableMap.Builder<String, Object> 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<BigInteger> 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<Boolean> 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<Hash> 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<Long> getForkBlockNumbers() {
Stream<OptionalLong> 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<Long> getForkBlockTimestamps() {
Stream<OptionalLong> 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.
*
* <p>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);
}
}

View File

@ -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
}

View File

@ -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<ValidatorVote> 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<Address> validatorsInBlock(final BlockHeader header) {
final BftExtraData bftExtraData = bftExtraDataCodec.decode(header);
return bftExtraData.getValidators();
}
/**
* Replace round in block.
*
* <p>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).
*
* <p>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<Address> 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());
}
}

View File

@ -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<SECPSignature> seals;
private final Collection<Address> validators;
private final Optional<Vote> 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<FalconSeal> 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<SECPSignature> seals,
final Optional<Vote> vote,
final int round,
final Collection<Address> 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<SECPSignature> seals,
final Optional<Vote> vote,
final int round,
final Collection<Address> validators,
final Collection<FalconSeal> 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<SECPSignature> getSeals() {
return seals;
}
/**
* Gets validators.
*
* @return the validators
*/
public Collection<Address> getValidators() {
return validators;
}
/**
* Gets vote.
*
* @return the vote
*/
public Optional<Vote> 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<FalconSeal> getFalconSeals() {
return falconSeals;
}
@Override
public String toString() {
return "BftExtraData{"
+ "vanityData="
+ vanityData
+ ", seals="
+ seals
+ ", validators="
+ validators
+ ", vote="
+ vote
+ ", round="
+ round
+ ", falconSeals="
+ falconSeals
+ '}';
}
}

View File

@ -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.
*
* <p>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())
+ '}';
}
}

View File

@ -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".
*
* <p>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.
*
* <p><b>Why V2 exists.</b> 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.
*
* <p><b>What V2 does instead.</b> 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.
*
* <p><b>The two pre-images.</b>
*
* <pre>
* 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
* </pre>
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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.
*
* <p>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<FalconSeal> 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.
*
* <p>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.
*
* <p>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<FalconSeal> 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.
*
* <p>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<FalconSeal> certificate) {
final java.util.Set<Integer> 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.
*
* <p><b>Why this exists.</b> 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.
*
* <p>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.
*
* <p><b>What the exception does NOT relax.</b> 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.
*
* <p><b>Producers must not call this.</b> {@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<FalconSeal> 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<FalconSeal> sortedByIndex(final Collection<FalconSeal> certificate) {
final List<FalconSeal> sorted = new ArrayList<>(certificate);
sorted.sort(BY_INDEX);
return sorted;
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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&gt;=9 before K is raised to
* quorum, because at N=7, f=2 the margin is exactly zero.
*
* <p>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.
*
* <p>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.
*
* <p>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;
}
}

View File

@ -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.
*
* <p><b>The hole this closes, read in the source and not assumed.</b> 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.
*
* <p><b>Why refusal and not coverage.</b> 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.
*
* <p><b>Fail closed on the unknown.</b> 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.
*
* <p><b>Inert when the anchor is not configured.</b> 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.
*
* <p>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.");
}
}

View File

@ -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.
*
* <p><b>The hole this closes, measured before it was written.</b> 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.
*
* <p><b>THE BOUND, AND WHERE THE ARITHMETIC COMES FROM.</b> The refusal is {@code K >= quorum(N)},
* i.e. the highest value that may be configured is {@code quorum(N) - 1}.
*
* <ul>
* <li>{@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 <b>4</b>.
* <li>{@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.
* <li><b>{@code K > N - f} is the WRONG bound and this guard deliberately does not use it.</b> 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.
* </ul>
*
* <p><b>What is a refusal and what is only a shout.</b> 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.
*
* <p><b>The verdict is computed from the SAME code path enforcement reads.</b> 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.
*
* <p><b>Fail closed on the unknown.</b> A validator-set size of zero or less aborts. "I could not
* count the validators" is not "the threshold is probably fine".
*
* <p><b>Inert when the anchor is not armed.</b> 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.
*
* <p>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.
*
* <p>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<Long, Integer> 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);
}
}

View File

@ -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.
*
* <h2>The defect, measured on the real code path on 2026-08-06</h2>
*
* <p>{@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.
*
* <p>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:
*
* <ul>
* <li>T3: rebinding one row's address credits a seal made by validator 0's key to validator 1.
* <li>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.
* <li>T6A: SWAPPING two rows' public keys - no duplicate key, no duplicate address - is accepted.
* <li>T6B: SWAPPING two rows' addresses - again no duplicate of anything - is accepted, and every
* seal is then attributed to the wrong validator.
* </ul>
*
* <p>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.
*
* <h2>What a row must now carry</h2>
*
* <p>Two proofs over ONE canonical pre-image, because one signature closes only half the defect:
*
* <ul>
* <li>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.
* <li>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.
* </ul>
*
* <h2>The canonical pre-image</h2>
*
* <pre>
* "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
* </pre>
*
* <p>Every field earns its place against a specific, named attack:
*
* <ul>
* <li>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.
* <li>publicKey IS IN THE SIGNED MESSAGE. Falcon is not Dilithium here: Cremers, Duzlu, Fiedler,
* Fischlin and Janson (IEEE S&amp;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.
* <li>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.
* <li>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.
* <li>count, so a registry cannot be truncated and have its surviving proofs stay valid.
* </ul>
*
* <p>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.
*
* <h2>Where this runs, and where it deliberately does not</h2>
*
* <p>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.
*
* <p>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.
*
* <h2>What this does NOT defend against, stated plainly</h2>
*
* <ul>
* <li>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.
* <li>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.
* <li>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.
* <li>NOT anything about how a private key is stored or whether it was ever copied.
* </ul>
*/
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.
*
* <p>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<PqRegistryHash.Entry> 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();
}
}

View File

@ -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.
*
* <p>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:
*
* <pre>
* java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
* verify --chain-id 2800 --genesis &lt;config-dir&gt;/genesis.json \
* --history &lt;config-dir&gt;/falcon/registry-epoca-0.properties
* </pre>
*
* <p>THREE VERBS.
*
* <ul>
* <li>{@code hash} prints the canonical hash of every registry file named. This is the value
* that goes into genesis.
* <li>{@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.
* <li>{@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.
* </ul>
*
* <p>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<String, String> 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<String, String> o, final long chainId) {
final List<Path> files = files(o, "registry");
if (files.isEmpty()) {
System.out.println("NOT MEASURED: --registry <file>[,<file>...] 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<String, String> o, final long chainId) {
final String armingHeightRaw = o.get("arming-height");
if (armingHeightRaw == null) {
System.out.println("NOT MEASURED: --arming-height <H> 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<Path> 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 <height>=<file>.
final Map<Long, Path> 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 <height>=<file>, got: " + rot);
return 2;
}
epoci.put(Long.parseLong(rot.substring(0, eq).trim()), Path.of(rot.substring(eq + 1).trim()));
}
final List<Long> 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 <genesis> --history <list>");
return 0;
}
// ---------------------------------------------------------------------------------------------
private static int verify(final Map<String, String> o, final long chainId) {
final String genesisRaw = o.get("genesis");
if (genesisRaw == null) {
System.out.println("NOT MEASURED: --genesis <file> 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<Path> history = files(o, "history");
final List<PqRegistryHash.Registry> 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<Long> 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<PqRegistryHash.Registry> 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<Path> files(final Map<String, String> o, final String cheie) {
final List<Path> out = new ArrayList<>();
for (final String s : list(o.get(cheie))) {
out.add(Path.of(s));
}
return out;
}
private static List<String> list(final String raw) {
final List<String> 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<String, String> optiuni(final String[] args) {
final Map<String, String> 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 <f>[,<f>...]");
System.out.println(" generate --chain-id 2800 --arming-height <H> --registry <f>");
System.out.println(" [--rotation <H2>=<f2> ...]");
System.out.println(" verify --chain-id 2800 --genesis <g.json> --history <f>[,<f>...]");
System.out.println(" [--arming-height <H>] [--CONTROL-NEGATIV]");
System.out.println();
System.out.println("Exit code: 0 green, 1 red, 2 not measurable.");
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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&gt;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.
*
* <p>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.
*
* <p>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<Hash, Entry> 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.
*
* <p>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.
*
* <p>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<FalconSeal> 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<FalconSeal> seals) {
final List<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<Map.Entry<Hash, Entry>> it = byHash.entrySet().iterator();
while (it.hasNext()) {
final Map.Entry<Hash, Entry> e = it.next();
if (highestSeen - e.getValue().blockNumber > HEIGHT_WINDOW) {
it.remove();
}
}
while (byHash.size() > MAX_ENTRIES) {
final Iterator<Hash> oldest = byHash.keySet().iterator();
oldest.next();
oldest.remove();
}
}
private static final class Entry {
private final long blockNumber;
private final Map<Integer, FalconSeal> seals = new LinkedHashMap<>();
private Entry(final long blockNumber) {
this.blockNumber = blockNumber;
}
}
}

View File

@ -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.
*
* <p><b>THE SECOND HALF OF A MEASURED CHAIN DEATH.</b> With the anchor armed at K&gt;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.
*
* <p><b>WHY THIS IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT, and the distinction is the
* whole safety argument.</b> 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.
*
* <p><b>WHAT THE FILE IS BOUND TO.</b> 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.
*
* <p><b>FAILURE IS NEVER FATAL.</b> 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.
*
* <p>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<FalconSeal> 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.
*
* <p>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.
*
* <p>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<FalconSeal> 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<FalconSeal> 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<FalconSeal> verified = new ArrayList<>();
final Set<Integer> 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.
*
* <p>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.
*
* <p>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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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()}.
*
* <p>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.
*
* <p>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.
*
* <p>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";
}
};
}
}

View File

@ -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 <T> the type parameter
*/
public class BftBlockCreatorFactory<T extends BftConfigOptions> {
/** The Forks schedule. */
protected final ForksSchedule<T> 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<T> 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.
*
* <p>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<VoteProvider> voteProviderAfterBlock =
validatorProvider.getVoteProviderAfterBlock(parentHeader);
checkState(voteProviderAfterBlock.isPresent(), "Bft requires a vote provider");
final Optional<ValidatorVote> proposal =
voteProviderAfterBlock.get().getVoteAfterBlock(parentHeader, localAddress);
final List<Address> 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<Vote> toVote(final Optional<ValidatorVote> input) {
return input.map(v -> new Vote(v.getRecipient(), v.getVotePolarity()));
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>WHAT IS SELECTED, in order, and each step is the mirror of a validator-side rule:
*
* <ol>
* <li>every seal heard for the parent, sorted by validator index (rule: strictly increasing);
* <li>dropped unless the index maps, through the address-bound Falcon registry, to an address in
* the validator set FOR THE PARENT (rule: eligible signer);
* <li>dropped unless the Falcon signature verifies over M(parent) (rule: k verifications);
* <li>refused entirely if fewer than K(N) survive (rule: k &gt;= K).
* </ol>
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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 <interval>. 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.
*
* <p>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<FalconSeal> heard =
PqSealCache.instance().sealsFor(parentNumber, parentHeader.getHash());
final PqSignerRegistry registry = PqSignerRegistry.falconSealSupport();
final Set<Address> eligible = validatorsForParent(parentHeader, protocolContext);
final List<FalconSeal> certificate = new ArrayList<>();
final Set<Address> 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.
*
* <p>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<Address> validatorsForParent(
final BlockHeader parentHeader, final ProtocolContext protocolContext) {
final Collection<Address> 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());
}
}
}

View File

@ -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}.
*
* <p>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.
*
* <pre>
* java -cp &lt;besu lib&gt; org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool \
* &lt;registry-file&gt; [--chain-id N] [--block H] [--quiet]
* </pre>
*
* <p>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 <H> 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 <registry-file> [--chain-id N] [--block H] [--quiet]\n"
+ " <registry-file> 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<hash>, for scripts\n"
+ "exit: 0 ok, 2 usage, 3 the registry file was rejected");
}
}

View File

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

View File

@ -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.
*
* <p>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<String, String> properties = new HashMap<>();
private final Map<String, String> environment = new HashMap<>();
private final List<String> 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<Long, Integer> schedule() {
final Map<Long, Integer> 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);
}
}

View File

@ -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.
*
* <p>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");
}
}

View File

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

View File

@ -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 "<H>:0" reaches the same state, and it used to pass.
*
* And it is not theoretical: the recommended activation schedule has the form "<H>:0,<H+165000>: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<String, String> 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();
}
}

View File

@ -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<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
private final List<Address> 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<FalconSeal> 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<Address> 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);
}
}

View File

@ -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<Long, Integer> 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<Long, Integer> 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);
}
}

View File

@ -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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> subsetA = Arrays.asList(seal(0), seal(1), seal(2), seal(3));
final List<FalconSeal> 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<FalconSeal> ordered = Arrays.asList(seal(0), seal(1));
final List<FalconSeal> 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<FalconSeal> original = List.of(new FalconSeal(3, signatureFor(3, 0)));
final List<FalconSeal> 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<FalconSeal> real1 = Arrays.asList(seal(2), seal(6), seal(1));
final List<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<Long, Integer> 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();
}
}

View File

@ -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.
*
* <p>WHAT THE WIRE BUYS, stated as the thing that is actually true. Before it, an ARMED node loaded
* a v1 registry without a word, and the registry decides who a Falcon seal is credited to. Measured
* on the real verification path on the same day: two rows with their public keys swapped - four
* distinct keys, four distinct addresses, so no uniqueness check would see anything - produced an
* ACCEPTED header; and the same key filed at two indices satisfied a threshold of two with one
* private key, which makes the threshold itself fiction.
*
* <p>WHY THE POSITIVE CONTROLS ARE THE EXPENSIVE HALF. A gate that refuses everything is not a gate,
* it is an outage wearing a security message. The tests that cost the most to get right here are the
* ones where the node STARTS: over a correct v2 registry, and over the very same v1 file when
* nothing is armed.
*
* <p>WHY THE ANCHOR CASE IS TESTED SEPARATELY FROM THE FORK-BLOCK CASE. They are different triggers
* and only one of them was previously guarded at all. {@code armingReadinessDiagnostic()} returns
* immediately when {@code aere.falcon.forkBlock} is unset, so AERE-PQC-REG-ARM-01 has never fired on
* a node armed through the certificate anchor. This guard fires on both, and {@link
* #armedThroughTheANCHORAloneTheNodeAlsoREFUSES} is the half that has no predecessor.
*
* <p>WHAT IS NOT MEASURED HERE, written rather than implied: nothing is deployed, no node is
* started, the fleet of seven is not touched, and every Falcon and ECDSA key below is a PROBE key
* generated in this JVM. Whether the refusal behaves the same on the seven real boxes at a
* coordinated restart is NOT MEASURED.
*/
public class 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<String> OWNED_PROPERTIES =
List.of(
"aere.falcon.registry",
"aere.falcon.forkBlock",
"aere.falcon.attachBlock",
"aere.falcon.validatorCount",
"aere.falcon.testnetAllowSmallFleet",
PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
PqAnchorConfig.PROPERTY_CHAIN_ID,
PqAnchorConfig.PROPERTY_MIN_SEALS);
@TempDir private Path tmp;
@BeforeEach
public void setUp() throws Exception {
clearOwnedProperties();
forgetAnchorConfig();
resetFalconSingleton();
}
@AfterEach
public void tearDown() throws Exception {
clearOwnedProperties();
forgetAnchorConfig();
resetFalconSingleton();
}
// -------------------------------------------------------------------------------------------
// 1. THE FINDING, on each of the two arming triggers.
// -------------------------------------------------------------------------------------------
/** An armed node over a registry with no binding proofs must refuse to start. */
@Test
public void armedOverAV1RegistryTheNodeREFUSESToStart() throws Exception {
armWithForkBlock(writeRegistry("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.
*
* <p>This is the case with no predecessor. AERE-PQC-REG-ARM-01 is raised by {@code
* armingReadinessDiagnostic()}, whose first statement is to return when the fork block is unset,
* so an anchor-armed node has never been asked ANY question about its registry's shape at startup.
*/
@Test
public void armedThroughTheANCHORAloneTheNodeAlsoREFUSES() throws Exception {
final Path v1 = writeRegistry("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.
*
* <p>The assertion that carries the weight is not the "starts" - it is the property sweep. A test
* that only asserted "does not throw" would keep passing if a later edit made the guard read some
* other property that happened to be set in this JVM. The sweep states the precondition as a
* measurement: at the moment the constructor runs, NO system property beginning with {@code
* aere.pq.} exists, and neither does the fork block.
*/
@Test
public void withNothingArmedTheGateIsInertOverTheSameV1Registry() throws Exception {
final Path v1 = writeRegistry("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.
*
* <p>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.
*
* <p>MEASURED, and the assertion was CHANGED to match the measurement rather than the other way
* round. The expectation written first was AERE-PQC-REG-ARM-02. What actually happens is a refusal
* one step EARLIER, at load, with AERE-PQC-REG-LOAD-21, because the loader counts proofs against
* claims and refuses a half-bound file before the arming gate ever sees it. That is the stronger
* of the two refusals - it holds whether or not the node is armed - so this is asserted on the
* code that actually fires.
*/
@Test
public void possessionWithoutAClaimIsRefusedEarlierStillAtLoad() throws Exception {
armWithForkBlock(writeRegistry("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<String> systemPropertiesStartingWith(final String prefix) {
final Properties p = System.getProperties();
return p.stringPropertyNames().stream()
.filter(n -> n.startsWith(prefix))
.sorted()
.collect(Collectors.toList());
}
private static void clearOwnedProperties() {
for (final String p : OWNED_PROPERTIES) {
System.clearProperty(p);
}
}
/**
* Force the anchor configuration to be re-read from system properties.
*
* <p>MEASURED 2026-08-06, and it is the reason this method exists rather than being assumed
* unnecessary. {@code PqAnchorProducer.config()} memoises the first configuration it ever builds,
* for the life of the JVM. That is CORRECT in production - a node is one JVM with one set of
* properties, and a configuration that could change underneath the consensus path would be worse
* than one that cannot. In a test JVM shared by every class in this module it means an anchor
* armed by an earlier test is still armed here, and {@link
* #withNothingArmedTheGateIsInertOverTheSameV1Registry} failed exactly that way before this call
* was added: the property sweep found no {@code aere.pq.*} and the node still refused, because
* the memo held another class's anchor.
*/
private static void forgetAnchorConfig() {
org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting(
null);
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -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.
*
* <p>WHAT THE FIRST SHAPE DID, MEASURED AND NOT ARGUED. On 2026-08-06 hardening (b) refused every
* unbound height at or above the arming height, deciding from the block NUMBER alone. Run against
* the suite that gave 588 tests and 0 failures on a clean tree, it gave 597 tests and 6 failures:
* five in {@code 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.
*
* <p>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.
*
* <p>WHAT SEPARATES THEM IS WHO SUPPLIES THE SUBJECT, and that is known at every call site and was
* being discarded at the interface boundary. So {@code PqSignerRegistry} now carries two named
* pairs, and the compiler forces every call site to say which question it is asking. This class is
* the proof that the two doors answer DIFFERENTLY at the SAME height, that the own-head door is not
* a loophole, and that the history door still refuses.
*
* <p>THIS CLASS CANNOT GO GREEN BY ACCIDENT. Three of its tests fail if the own-head door is made
* to refuse (which is the first shape restored), and three fail if the history door is made to
* answer (which is the pre-2026-08-06 defect restored). The two plants are run in opposite
* directions, and 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<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
private final List<Address> validators = new ArrayList<>();
private Path genesisPath;
/** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */
private static final Bytes32 MESSAGE = Bytes32.fromHexString("0x" + "5a".repeat(32));
private Bytes sealByIndexZero;
@BeforeEach
public void setUp() throws Exception {
// AERE 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<Long, String> entries = new LinkedHashMap<>();
entries.put(H, hash);
entries.put(rotation, "0x" + "cd".repeat(32));
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries));
assertThat(pqc.verifyAtOwnHead(rotation, 0, MESSAGE, sealByIndexZero))
.describedAs(
"a rotation the chain HAS scheduled and this node does NOT hold is not a missing "
+ "binding, it is a node running a registry the chain has moved off. If the "
+ "own-head door answered here it would be a way to sign blocks under a retired "
+ "key set, and the split would have bought a liveness fix at the price of the "
+ "property the whole anchor exists for")
.isFalse();
assertThat(pqc.addressForIndexAtOwnHead(rotation, 0)).isNull();
assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero))
.describedAs("and the history door refuses identically")
.isFalse();
assertThat(pqc.verifyAtOwnHead(rotation - 1L, 0, MESSAGE, sealByIndexZero))
.describedAs("positive control: below the rotation this node holds the epoch and answers")
.isTrue();
assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero)).isTrue();
}
// -------------------------------------------------------------------------------------------
// 5. Negative control on the split itself: with the binding CONFIGURED, the two doors converge.
// If they do not, the own-head door is not a fallback rule, it is a second key set.
// -------------------------------------------------------------------------------------------
@Test
public void withTheScheduleConfiguredBOTHDOORSGiveTheSameAnswer() throws Exception {
final FalconSealSupport pqc = FalconSealSupport.instance();
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
final Map<Long, String> entries = new LinkedHashMap<>();
// AERE 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<Long, String> entries)
throws Exception {
final List<Long> heights = new ArrayList<>(entries.keySet());
heights.sort(Long::compare);
final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
for (int i = 0; i < heights.size(); i++) {
if (i > 0) {
sb.append(',');
}
final long b = heights.get(i);
String h = entries.get(b);
if (!h.startsWith("0x")) {
h = "0x" + h;
}
sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}");
}
sb.append("]}}");
final Path p =
tmp.resolve("genesis-schedule-v2-" + heights.size() + "-" + heights.get(0) + ".json");
Files.writeString(p, sb.toString());
return PqRegistryHash.loadScheduleFromGenesis(p);
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -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.
*
* <p>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&gt;0 a SIMULTANEOUS restart of every validator stops
* the chain for good. The node said it verbatim: "refusing to
* propose ... holds 0 valid eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF
* (fail-safe)".
*
* <p>THE CIRCLE. {@code activateLateAnchor()} used to be reachable from exactly one place, {@code
* FalconSealValidationRule.tryActivateLateAnchor}, which runs only while a block is being IMPORTED.
* After a fleet restart no block is imported, because nobody proposes. So {@code lateActivated}
* stays false, {@link FalconSealSupport#attachmentArmed(long)} answers false, no seal is attached,
* no certificate reaches K, and nobody can propose. Seals come from Commits, Commits come from
* proposals, proposals need seals. With K=0 the chain heals itself. With K&gt;0 it never does.
*
* <p>WHAT THIS CLASS MEASURES, and it is the state machine the repair moves, not a paraphrase of
* it. The repair (QbftBesuControllerBuilder, marker "AERE BLOCAJ-REPORNIRE") adds a SECOND caller of
* the SAME method at startup, reading the SAME contract slot 0 out of the chain-head world state.
* So the question that decides whether the repair can work is exactly: does calling {@code
* activateLateAnchor} with the on-chain hash, with no block imported and no other stimulus, turn
* {@code attachmentArmed()} from false to true. Below, it does.
*
* <ol>
* <li>{@link #restartedFleetIsNotArmedAndDoesNotHealWithTime()} - the deadlock state itself. A
* node whose late anchor is PENDING is past its attachment height and still refuses to
* attach, at that height and at every height after it. Nothing in the process flips it.
* <li>{@link #activatingFromTheChainHeadArmsAttachment()} - the repair's mechanism. One call with
* the on-chain hash, and attachment is armed. This is the ONLY thing the startup code adds.
* <li>{@link #aWrongOnChainHashLeavesAttachmentOffAndIsTerminal()} - THE NEGATIVE CONTROL. If the
* hash does not match, activation must FAIL and attachment must stay OFF: the repair must not
* have bought liveness by weakening the tamper check. It also stays terminally failed, so a
* later correct hash does not resurrect it.
* <li>{@link #activationIsIdempotentAcrossRepeatedStartupCalls()} - the scope control. The
* startup call and the import-path call can both fire in one process; the second must be a
* no-op rather than a second registry load.
* <li>{@link #aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart()} - the rehearsal's own
* stimulus replayed against THIS tree, and it does not fail the way the network did. Read its
* javadoc: the rehearsal binary predates the seal-attachment repair, and the line it logged
* came from a condition this tree no longer contains.
* </ol>
*
* <p>NOT MEASURED here, and named so it is not read as covered: that a real Besu process reads slot
* 0 out of a real chain-head world state (that is world-state plumbing in the app module, and the
* rehearsal network is the instrument for it), and that seven live nodes recover from a real
* simultaneous restart with this binary. This class measures the decision the deadlock hinges on.
*/
public class 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.
*
* <p>So this test states what is true HERE: a genesis-anchored node, freshly constructed, with no
* validator set observed and no block imported, IS armed. The rehearsal's measured deadlock is
* closed for the genesis-anchored path by a repair that already landed - and NOT by the startup
* repair this class is about.
*
* <p>Which is exactly why the startup repair is still needed: on the LATE-ANCHOR path, the one
* the live chain must use because it cannot be re-genesised, {@code lateActivated} is still set
* from one place only. Tests 1-3 measure that path.
*
* <p>NOT MEASURED: that seven live nodes on a genesis-anchored network recover from a
* simultaneous restart with a binary built from this tree.
*/
@Test
public void aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart() throws Exception {
System.clearProperty("aere.falcon.manifest");
System.clearProperty("aere.falcon.anchor.address");
System.clearProperty("aere.falcon.anchor.block");
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
resetFalconSingleton();
final FalconSealSupport pqc = FalconSealSupport.instance();
assertThat(pqc.genesisAnchored())
.describedAs("fixture control: the genesis manifest must verify against the anchored hash")
.isTrue();
assertThat(pqc.registrySize()).isEqualTo(N);
assertThat(pqc.addressBound()).isTrue();
assertThat(pqc.attachmentArmed(HEAD))
.describedAs(
"a genesis-anchored node arms with NO validator set observed and NO block imported. "
+ "The rehearsal's coverage condition is gone from this tree.")
.isTrue();
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>Both halves of the finding are about the SAME shape of defect, the one the Holesky Pectra
* incident of February 2025 made expensive for everybody: a fork-activation parameter that is wrong
* or absent does not stop the node, it changes what the node silently believes. Half one is the
* value itself. Half two is the ORDER between that value and the height at which the registry the
* value depends on becomes active.
*
* <p>WHAT EACH TEST MEASURES, and how each can fail:
*
* <ol>
* <li>{@link #controlAWellFormedLateAnchorConfigurationStarts()} - the fixture's own negative
* control. If the late-anchor manifest did not load, or the singleton were not really being
* rebuilt, every refusal below would be a refusal for the wrong reason.
* <li>{@link #aMalformedForkBlockRefusesToStart()} and {@link #aNegativeForkBlockRefusesToStart()}
* - the config-time half of the finding, at construction.
* <li>{@link #theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup()} - the RESIDUAL half
* one. The startup guard only ever looked at the property once, but {@code forkBlock()}
* re-read the property on every call and fell back to "never blocking" with a log line on
* anything it could not parse. A guard that validates a value it does not then own is not a
* guard; this test drives that exact gap.
* <li>{@link #blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses()} - half two. A
* blocking height is stated over a registry that is not active yet and whose activation
* height is nowhere stated, so nothing in the process can compare the two.
* <li>{@link #anAttachHeightBeforeTheObservationHeightRefuses()} and {@link
* #aForkHeightAtTheObservationHeightRefuses()} - half two on its own stimulus: the ordering
* is wrong and the node starts anyway.
* <li>{@link #aMalformedObservationHeightRefuses()} and {@link
* #aNegativeObservationHeightRefuses()} - the new value must fail closed like every other
* {@code aere.falcon.*} value. A half-fail-closed property set is worse than either extreme.
* <li>{@link #aGenesisAnchoredRegistryNeedsNoObservationHeight()} and {@link
* #anObservationHeightWithoutBlockingIsHarmless()} - the scope controls. A guard that refused
* every blocking configuration would pass every test above and be useless.
* </ol>
*
* <p>NOT MEASURED here, deliberately, and named so it is not mistaken for covered: whether a real
* Besu node process exits with a non-zero status when this exception is thrown. This class measures
* the decision, not the process. The exception is thrown from the constructor, on the same path as
* the guards that already abort, and nothing in this tree catches {@code
* FalconSealSupport.ActivationConfigException}.
*/
public class 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);
}
}

View File

@ -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?
*
* <p>The 2026-08-02 repair closed the mechanism that stopped the chain on one add-validator vote: it
* took the fleet-wide coverage question out of the per-commit attachment gate and made coverage a
* REPORT. That repair is correct and it is measured next door in {@code PqForkValidatorSetChangeTest}.
* But it left behind an explicit promise, written in the javadoc of {@code attachmentArmed}:
*
* <blockquote>
*
* "What coverage genuinely protects - that blocking is not ARMED over a partial manifest - is an
* arm-time decision, and it is made at arm time by armingReadinessDiagnostic() and by the operator".
*
* </blockquote>
*
* <p>MEASURED 2026-08-03: {@code armingReadinessDiagnostic()} checks exactly one thing, whether the
* manifest is ADDRESS-BOUND. It never reads the fleet size, never reads how many validators hold an
* anchored key, and never reads K. The arm-time decision the comment names did not exist, so the
* compensating control for the repair was a sentence. This class is what makes it exist.
*
* <p>THE ARITHMETIC, which is the whole finding and is not an opinion. A block needs {@code
* ceil(2N/3)} ECDSA committers, and Falcon seals ride on Commit messages, so the seals a proposer is
* GUARANTEED to hear are only those of the keyed validators it cannot avoid: {@code quorum - (N -
* keyed)}. The row that matters for this project:
*
* <pre>
* N=7, keyed 7, quorum 5 -&gt; 5 guaranteed K=5 reachable, margin exactly 0
* N=9, keyed 7, quorum 6 -&gt; 4 guaranteed K=5 NOT guaranteed
* </pre>
*
* <p>The second row is the standing plan. "Grow to N=9 BEFORE arming" is right, and if the manifest
* is not re-anchored on the way there it produces a fleet that arms a threshold no proposer is
* guaranteed to meet. Before this guard a node in that state started, joined, armed, and the failure
* appeared later as a proposer that could not propose. That is the most expensive shape a
* configuration error can take, and it is the same shape the address-binding guard already refused
* to allow for a non-address-bound manifest.
*
* <p>WHY AT CONFIG TIME AND NOWHERE ELSE. The lesson is borrowed, not invented: CometBFT applies a
* validator-set change only at H+2 and Ethereum's light-client protocol carries {@code
* next_sync_committee} a whole period ahead, both so that the set a cryptographic check runs over is
* known and comparable BEFORE the boundary rather than discovered at it. We cannot copy their
* mechanism, because at seven nodes under one operator there is no committee to sample. We can copy
* the discipline: DECLARE the fleet size, compare it against the threshold at config time, and
* refuse to cross the boundary if the comparison fails. The same reasoning already produced
* AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in the fork-arming
* configuration guard.
*
* <p>NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds
* between the vote landing and the first proposer failing. That needs a network. This class measures
* the decision, which is the thing a node can be stopped from taking.
*/
public class 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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>That is true of a whole chain. It is NOT true of the decision that stops it. Every step from
* "the validator set changed" to "no block can be proposed" is taken by three objects in this
* module, each of which is a pure function of its inputs: {@link FalconSealSupport#attachmentArmed}
* decides whether this node emits a Falcon seal at all, {@link PqSealCache} holds what was heard,
* and {@link PqAnchorProducer#apply} decides whether this node may propose. This class drives those
* three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the
* question that was held to be unaskable.
*
* <p>WHAT EACH TEST MEASURES, and why each of them can fail:
*
* <ol>
* <li>{@link #baselineTheGateIsArmedWhileTheRegistryCoversTheSet()} - the negative control for
* every other test here. If the gate were simply always off, or the registry never loaded,
* the three tests below would "pass" for a reason that has nothing to do with the question
* under test. This one fails if the fixture is not genuinely armed.
* <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - the concern itself, on the exact
* stimulus in the title: one more validator in the set, with no Falcon key.
* <li>{@link #aNodeStartedAboveTheAnchorHeightMustStillAttach()} - the SAME halt through a much
* more ordinary door than a vote: a restart. Above the anchor height the only caller of
* {@code observeValidators} has retired, so a node that starts there never observes a
* validator set at all.
* <li>{@link #theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas()} - the causal
* link, measured in both directions, so that "attachment off" to "chain stopped" is not an
* assertion. Nothing heard: the proposer throws and cannot propose. Five real seals heard:
* the proposer produces extraData carrying a five-seal certificate.
* </ol>
*
* <p>NOT MEASURED here, deliberately, and named so it is not mistaken for covered: how many rounds a
* live fleet takes to stop once every proposer refuses, and what a syncing node does meanwhile.
* Those need a network, and the network run is separate evidence.
*/
public class 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<Address> keyedValidators = new ArrayList<>();
private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
private Address newcomer;
private Path genesisPath;
private Path key0Path;
@BeforeEach
public void setUp() throws Exception {
// The genesis-anchored path is only satisfied when keccak256(addr20 || pk, indices ascending)
// equals the hash stored in the genesis alloc. The digest is accumulated here in lockstep with
// the manifest text, so the fixture is anchored the same way a real genesis is.
//
// 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<FalconSeal> 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<Address> 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<FalconSeal> heard = new ArrayList<>();
for (int i = 0; i < K; i++) {
heard.add(new FalconSeal(i, Bytes.wrap(falconSign(privateKeys.get(i), m))));
}
PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard);
final BftExtraData produced = PqAnchorProducer.apply(base, parent, context);
assertThat(produced.getFalconSeals()).hasSize(K);
assertThat(produced.getVanityData())
.isEqualTo(
PqAnchor.anchorDigest(
CHAIN_ID,
parent.getNumber(),
parent.getHash().getBytes(),
PqAnchor.sortedByIndex(heard)));
}
// -----------------------------------------------------------------------------------------
// 4b. What an unkeyed validator actually costs, as a number rather than as a worry.
// -----------------------------------------------------------------------------------------
/**
* Removing coverage from the attachment gate stops the halt; it does not make adding an unkeyed
* validator free. The price is the guaranteed number of anchored-key holders among a block's ECDSA
* committers, and it is arithmetic, not opinion: a block needs {@code ceil(2N/3)} committers, and
* the unluckiest committer set takes every unkeyed validator first.
*
* <p>The three rows below are the ones that decide the project's own arming order, so they are
* measured here rather than reasoned about in a document:
*
* <pre>
* N=7, keyed 7, quorum 5 -&gt; 5 guaranteed K=5 is met, with EXACTLY zero margin
* N=8, keyed 7, quorum 6 -&gt; 5 guaranteed K=5 is still met, still zero margin
* N=9, keyed 7, quorum 6 -&gt; 4 guaranteed K=5 is NOT guaranteed any more
* </pre>
*
* <p>Read against the standing rule "grow to N=9 BEFORE arming", that third row is the warning:
* growing to nine while the anchored manifest still names seven is exactly the state in which a
* proposer can legitimately fail to assemble a certificate. The manifest has to grow with the set.
*/
@Test
public void theCostOfAnUnkeyedValidatorIsANumberAndTheNumberIsThis() {
assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 7))
.describedAs("N=7 fully keyed: K=5 is met with zero margin")
.isEqualTo(5);
assertThat(FalconSealSupport.worstCaseKeyedSigners(8, 7))
.describedAs("one validator added without re-anchoring: K=5 still met, still zero margin")
.isEqualTo(5);
assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 7))
.describedAs(
"two added without re-anchoring: below K=5, so a proposer can legitimately fail. This "
+ "is the row that constrains growing to N=9 before arming.")
.isEqualTo(4);
assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 0)).isZero();
assertThat(FalconSealSupport.worstCaseKeyedSigners(0, 0)).isZero();
}
// -----------------------------------------------------------------------------------------
// 5. NEGATIVE CONTROL for this whole file: the gate must still refuse what it must refuse.
// -----------------------------------------------------------------------------------------
/**
* Every other test here asserts that the gate says YES. Replace {@code attachmentArmed} with
* {@code return true} and all of them still pass, which would make this file a proof that cannot
* fail. These four assertions are what makes that substitution impossible: each names a condition
* the 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<Address> validators) {
final ValidatorProvider validatorProvider =
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
final BftContext bftContext =
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
when(bftContext.as(any())).thenReturn(bftContext);
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>WHY THE SILENCE IS MEASURED AND NOT ASSUMED. "It returns early, so it cannot log" is a reading
* of the code, not a measurement, and the integrated tree has four patches whose log sites nobody
* has looked at together. Here the actual Log4j2 pipeline is tapped and the lines are counted.
*
* <p>WHY {@link #positiveControlTheCaptorSEESTheAnchorWhenItISArmed} is not optional. A captor that
* attaches to nothing reports silence forever, and every assertion in {@link
* #withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING} would pass against a broken tap.
* The positive control arms the anchor and requires that the SAME captor, in the same JVM, sees the
* producer's activation line. Without it this class would be a proof that cannot go red.
*
* <p>WHY THE CAPTOR IS BUILT BY REFLECTION. {@code log4j-core}, which owns the appender API, is on
* this module's RUNTIME test classpath but not its COMPILE one - measured, not assumed. Adding it as
* a compile dependency would put a build file into the AERE overlay, which until now is Java only.
* Reflection keeps the overlay unchanged, and the positive control is what makes it safe: if any of
* the reflective steps silently failed, the captor would see nothing and the positive control would
* be the test that fails.
*
* <p>NOT MEASURED, and written rather than implied: nothing is deployed and no node is started. That
* an unarmed node on one of the seven real boxes behaves this way over a real chain, at 523 ms
* blocks, alongside a peer that IS armed, is NOT MEASURED and needs the rehearsal network.
*/
public class PqInertBinaryTest {
/**
* Loggers that exist ONLY because of the anchor work, so any line from them on an unarmed node is
* by itself a finding.
*
* <p>{@code FalconSealSupport} is deliberately NOT here even though it is the loudest of them.
* It predates the anchor and legitimately says one thing at startup; listing it would make the
* filter report a four-year-old INFO line as new anchor chatter. Its armed messages are caught by
* {@link #ANCHOR_WORDS} instead, which keys on what the line SAYS rather than who said it.
*/
private static final List<String> ANCHOR_LOGGERS =
List.of(
"org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer",
"org.hyperledger.besu.consensus.common.bft.PqAnchorConfig",
"org.hyperledger.besu.consensus.common.bft.PqSealStore",
"org.hyperledger.besu.consensus.common.bft.PqRegistryBinding");
/**
* Words that name the ANCHOR - the capability these patches add - in a message body, whichever
* logger emitted it. Deliberately narrower than "anything mentioning Falcon": the Falcon registry
* predates all of this, and a filter that cannot tell the new surface from the old one would call
* a pre-existing line a regression.
*/
private static final List<String> ANCHOR_WORDS =
List.of("pq-anchor", "anchor", "aere-pqc", "aere pqc d2");
/**
* THE ONE LINE an unarmed node has always written, quoted so that any NEW startup chatter turns
* this class red.
*
* <p>MEASURED 2026-08-06, and it is the reason this constant exists rather than an {@code
* isEmpty()} on everything. The first shape of this test asserted total silence and went red on
* this line. It is not a regression: {@code git log -S} places it in commit 307fd0d0, the snapshot
* of everything built between 14 June and 2 August, so it predates all three anchor patches and
* the arming gate. It is {@code LOG.info} and it says the node has no Falcon registry, which is
* true and was equally true of the binary being replaced.
*
* <p>So the property that is actually worth defending is not "says nothing" - that was never true
* - but "says nothing NEW, and nothing about the anchor". Pinning the exact text is what makes the
* second half enforceable: a fourth patch that adds one more startup line has to come here and
* change this constant deliberately.
*/
private static final String THE_ONE_PRE_EXISTING_LINE =
"AERE PQC: no Falcon registry configured "
+ "(aere.falcon.genesis/aere.falcon.manifest/aere.falcon.registry); "
+ "hybrid seal verification will be a no-op.";
private static final long CHAIN_ID = 220_878L;
private static final long H = 4_000L;
/**
* Every property this class may touch. The unarmed test does not rely on this list - it sweeps the
* whole property table - but the armed one must put back exactly what it took.
*/
private static final List<String> OWNED_PROPERTIES =
List.of(
"aere.falcon.registry",
"aere.falcon.forkBlock",
"aere.falcon.attachBlock",
"aere.falcon.validatorCount",
"aere.falcon.testnetAllowSmallFleet",
PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
PqAnchorConfig.PROPERTY_CHAIN_ID,
PqAnchorConfig.PROPERTY_MIN_SEALS);
@BeforeEach
public void setUp() throws Exception {
clearOwnedProperties();
forgetAnchorConfig();
resetFalconSingleton();
}
@AfterEach
public void tearDown() throws Exception {
clearOwnedProperties();
forgetAnchorConfig();
resetFalconSingleton();
}
// ---------------------------------------------------------------------------------------------
// THE PROPERTY.
// ---------------------------------------------------------------------------------------------
/**
* A node carrying the integrated binary and no anchor configuration starts, produces a block, and
* logs nothing about the anchor.
*
* <p>The block-production half is asserted on OBJECT IDENTITY, not equality. {@code
* PqAnchorProducer.apply} returns its argument unchanged at the first branch when the anchor is
* not active; an equal-but-rebuilt {@code BftExtraData} would mean the producer had walked the
* certificate path and merely arrived back at the same value, which is a different and much
* weaker statement.
*/
@Test
public void withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING() throws Exception {
// The precondition is MEASURED over the whole property table rather than trusted to the
// teardown of whatever test ran before this one in this JVM.
assertThat(systemPropertiesStartingWith("aere."))
.describedAs("the precondition of this test, measured rather than assumed")
.isEmpty();
final LogCaptor captor = LogCaptor.attach();
final BftExtraData produced;
final BftExtraData base = plainExtraData();
try {
assertThatCode(FalconSealSupport::instance)
.describedAs("a box with no key ceremony behind it must still come up")
.doesNotThrowAnyException();
final BlockHeader parent = new BlockHeaderTestFixture().number(H + 500L).buildHeader();
produced = PqAnchorProducer.apply(base, parent, contextWith(List.of()));
} finally {
captor.detach();
}
assertThat(produced)
.describedAs(
"the unarmed producer must hand back the very object it was given; an equal copy would "
+ "mean it had walked the certificate path")
.isSameAs(base);
assertThat(PqAnchorProducer.config().everActive())
.describedAs("and it must consider itself never-active, not merely inactive right now")
.isFalse();
assertThat(captor.anchorLines())
.describedAs(
"an operator staging this binary before the height must see NOTHING about the anchor; "
+ "%d line(s) in total were seen, so the captor was live",
captor.total())
.isEmpty();
// And nothing NEW of any kind. This is the half that catches a future patch adding chatter.
assertThat(captor.aereLines())
.describedAs(
"the whole AERE output of an unarmed node, pinned: exactly the one INFO line that "
+ "predates these patches (commit 307fd0d0). A new line here is a staging "
+ "regression even when it is harmless, because it changes what the fleet prints "
+ "on a restart that is supposed to be a no-op.")
.containsExactly(THE_ONE_PRE_EXISTING_LINE);
}
// ---------------------------------------------------------------------------------------------
// THE POSITIVE CONTROL, without which the test above proves nothing.
// ---------------------------------------------------------------------------------------------
/**
* The same captor, the same JVM, the same loggers - with the anchor armed. If this does not see a
* line, the silence measured above is the silence of a broken tap and means nothing.
*
* <p>The line chosen is the producer's own activation notice, emitted from {@code
* PqAnchorProducer.config()} the first time a configuration is built. Its once-per-JVM latch is
* reset by {@code useConfigForTesting(null)}, which is why {@link #forgetAnchorConfig()} runs
* before every test in this class.
*/
@Test
public void positiveControlTheCaptorSEESTheAnchorWhenItISArmed() throws Exception {
System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
// 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<Address> validators) {
final ValidatorProvider validatorProvider =
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
final BftContext bftContext =
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
when(bftContext.as(any())).thenReturn(bftContext);
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
}
private static List<String> systemPropertiesStartingWith(final String prefix) {
final Properties p = System.getProperties();
return p.stringPropertyNames().stream()
.filter(n -> n.startsWith(prefix))
.sorted()
.collect(Collectors.toList());
}
private static void clearOwnedProperties() {
for (final String p : OWNED_PROPERTIES) {
System.clearProperty(p);
}
}
private static void forgetAnchorConfig() {
PqAnchorProducer.useConfigForTesting(null);
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
/**
* A Log4j2 appender built as a dynamic proxy and attached to the root logger, so that this module
* can read the real logging pipeline without taking a compile dependency on {@code log4j-core}.
*
* <p>{@link #attach()} throws if any reflective step fails. It does NOT fall back to a silent
* captor: a captor that quietly captures nothing is precisely the failure this class is written to
* exclude.
*/
private static final class LogCaptor {
private final List<String> lines = Collections.synchronizedList(new ArrayList<>());
private final Object rootLoggerConfig;
private final Object loggerContext;
private final Level priorLevel;
private LogCaptor(
final Object rootLoggerConfig, final Object loggerContext, final Level priorLevel) {
this.rootLoggerConfig = rootLoggerConfig;
this.loggerContext = loggerContext;
this.priorLevel = priorLevel;
}
static LogCaptor attach() throws Exception {
final Class<?> appenderCls = Class.forName("org.apache.logging.log4j.core.Appender");
final Class<?> eventCls = Class.forName("org.apache.logging.log4j.core.LogEvent");
final Class<?> configCls = Class.forName("org.apache.logging.log4j.core.config.Configuration");
final Class<?> loggerConfigCls =
Class.forName("org.apache.logging.log4j.core.config.LoggerConfig");
final Class<?> filterCls = Class.forName("org.apache.logging.log4j.core.Filter");
final Class<?> ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext");
final Class<?> stateCls = Class.forName("org.apache.logging.log4j.core.LifeCycle$State");
// LogManager is reached reflectively as well, not out of symmetry but because the build bans
// the symbol: [BannedMethod] "Do not use org.apache.logging.log4j.LogManager, use
// org.slf4j.LoggerFactory instead", and the ban is right for production code. A test that
// needs to inspect the logging pipeline itself is the one place it cannot be honoured, and
// going through the SLF4J facade cannot reach the appender list at all.
final Class<?> logManagerCls = Class.forName("org.apache.logging.log4j.LogManager");
final Object ctx =
logManagerCls.getMethod("getContext", boolean.class).invoke(null, Boolean.FALSE);
if (!ctxCls.isInstance(ctx)) {
throw new IllegalStateException(
"the SLF4J binding in this JVM is not log4j-core, so the log cannot be tapped: "
+ ctx.getClass().getName());
}
final Object configuration = ctxCls.getMethod("getConfiguration").invoke(ctx);
final Object rootLoggerConfig = configCls.getMethod("getRootLogger").invoke(configuration);
final List<String> sink = Collections.synchronizedList(new ArrayList<>());
final Method getMessage = eventCls.getMethod("getMessage");
final Method getLoggerName = eventCls.getMethod("getLoggerName");
Object startedState = null;
for (final Object c : stateCls.getEnumConstants()) {
if ("STARTED".equals(((Enum<?>) c).name())) {
startedState = c;
}
}
final Object started = startedState;
final InvocationHandler handler =
(proxy, method, args) -> {
switch (method.getName()) {
case "append":
final Object event = args[0];
final Object msg = getMessage.invoke(event);
final String text =
(String) msg.getClass().getMethod("getFormattedMessage").invoke(msg);
sink.add(getLoggerName.invoke(event) + " | " + text);
return null;
case "getName":
return "aere-d147-captor";
case "isStarted":
return Boolean.TRUE;
case "isStopped":
return Boolean.FALSE;
case "getState":
return started;
case "ignoreExceptions":
return Boolean.TRUE;
case "equals":
return proxy == args[0];
case "hashCode":
return System.identityHashCode(proxy);
case "toString":
return "aere-d147-captor";
default:
return null;
}
};
final Object appender =
Proxy.newProxyInstance(
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<String> bound;
private void bind(final List<String> sink) {
this.bound = sink;
}
void detach() throws Exception {
final Class<?> loggerConfigCls =
Class.forName("org.apache.logging.log4j.core.config.LoggerConfig");
final Class<?> ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext");
loggerConfigCls
.getMethod("removeAppender", String.class)
.invoke(rootLoggerConfig, "aere-d147-captor");
loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, priorLevel);
ctxCls.getMethod("updateLoggers").invoke(loggerContext);
lines.addAll(bound);
}
int total() {
return lines.size();
}
/** Every captured line that names the ANCHOR, by logger or by wording. */
List<String> anchorLines() {
return lines.stream()
.filter(
l -> {
final String lower = l.toLowerCase(Locale.ROOT);
return ANCHOR_LOGGERS.contains(loggerOf(l))
|| ANCHOR_WORDS.stream().anyMatch(lower::contains);
})
.collect(Collectors.toList());
}
/** Every captured message body that AERE code emitted, logger prefix stripped. */
List<String> aereLines() {
return lines.stream()
.filter(l -> loggerOf(l).contains(".bft") || l.contains("AERE"))
.map(l -> l.substring(l.indexOf(" | ") + 3))
.collect(Collectors.toList());
}
private static String loggerOf(final String line) {
final int i = line.indexOf(" | ");
return i < 0 ? "" : line.substring(0, i);
}
}
}

View File

@ -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.
*
* <p>WHAT WAS MEASURED BEFORE THIS TEST EXISTED, on the real verification path, with the startup
* gate reporting MATCH and the header ACCEPTED every time:
*
* <ul>
* <li>T3, one row's address repointed: a seal made by validator 0's key credited to validator 1.
* <li>T4, one key at two indices: two seals from a SINGLE private key satisfy a threshold of 2.
* <li>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.
* <li>T6B, two rows' addresses swapped: accepted, again with nothing duplicated, and every seal
* attributed to the wrong validator.
* </ul>
*
* <p>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.
*
* <p>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<Holder> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> 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<Row> rows() {
return rows(CHAIN_ID);
}
private static List<Row> rows(final long chainId) {
final List<Row> 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<Row> 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<Row> 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<Row> 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();
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>WHAT THIS FILE ASSERTS, as a property and not as a scenario: <b>at and above the arming height,
* a node that cannot say which key set was in force must REFUSE, not guess.</b> Below the arming
* height it must keep answering from the head registry, because nothing there is being judged and
* the 11.8 million blocks already on chain 2800 must behave bit for bit as they did.
*
* <p>THE NEGATIVE CONTROL IS BUILT IN, not promised. {@link
* #belowTheArmingHeightTheHeadRegistryStillAnswers()} fails if the refusal is made unconditional;
* {@link #whenTheAnchorIsNotArmedNOTHINGCHANGES()} fails if it is made independent of arming; {@link
* #withTheScheduleConfiguredTheArmedHeightsAnswerAgain()} fails if the refusal is anything other
* than a missing height-to-registry binding. And the measurement itself, {@link
* #d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal()}, is GREEN on the unrepaired code
* only if the fallback is restored - which is exactly the one-line edit the repair removed.
*/
public class 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<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
private final List<Address> validators = new ArrayList<>();
private Path genesisPath;
/** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */
private static final Bytes32 MESSAGE =
Bytes32.fromHexString("0x" + "5a".repeat(32));
private Bytes sealByIndexZero;
@BeforeEach
public void setUp() throws Exception {
// AERE 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<Long, String> entries)
throws Exception {
final List<Long> heights = new ArrayList<>(entries.keySet());
heights.sort(Long::compare);
final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
for (int i = 0; i < heights.size(); i++) {
if (i > 0) {
sb.append(',');
}
final long b = heights.get(i);
String h = entries.get(b);
if (!h.startsWith("0x")) {
h = "0x" + h;
}
sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}");
}
sb.append("]}}");
final Path p = tmp.resolve("genesis-schedule-" + heights.size() + "-" + heights.get(0) + ".json");
Files.writeString(p, sb.toString());
return PqRegistryHash.loadScheduleFromGenesis(p);
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -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.
*
* <p>WHAT IS MEASURED HERE, and why it is measured against the real classes rather than described.
* {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the 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.
*
* <p>The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code
* FalconSealSupport.registryBindingSatisfiedAt(height)}, which calls it. Both take exactly ONE
* loaded registry, and the node loads exactly one file ({@code aere.falcon.registry}). The entry the
* schedule makes active at a height decides which hash is required THERE. So after one rotation at
* H2 there are two intervals with two different required hashes, and one file can satisfy at most
* one of them.
*
* <p>The consequence is not cosmetic and it is not confined to the rotation moment. 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.
*
* <p>This is the lesson Cosmos ADR-016 writes down explicitly: a rotation scheme has to keep the
* MAPPING FROM HEIGHT TO KEY SET, not only the current key set, or blocks signed under the old set
* stop being verifiable. Cosmos may bound that history by the unbonding period. We may not: chain
* 2800 has no unbonding period and a node syncing from genesis must verify every block that was ever
* produced, so every entry ever scheduled has to stay loadable forever.
*
* <p>{@code rotationDoesNotBrickHistory} and {@code revocationDoesNotBrickHistory} are the
* measurement. They FAIL while the defect is present and pass only when a node can be configured to
* satisfy the binding at EVERY scheduled height at once. The other tests are controls: they assert
* that the schedule really does express rotation and really does refuse a malformed one, so a
* failure of the two measurements cannot be blamed on the fixture.
*/
public class 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?
*
* <p>It is an interface and not a Registry so that the measurement can be stated once and asked of
* every configuration a node can actually be put into. Today there is exactly one shape of answer,
* {@link #single}, because a node loads one file. A repair that lets a node hold the whole
* scheduled history adds a second shape here and the assertion below stops failing. Nothing in the
* assertion has to change, which is the point: the property is fixed, the capability is what moves.
*/
private interface NodeConfiguration {
boolean satisfiesAt(long height);
String describe();
}
private static NodeConfiguration single(
final String name, final PqRegistryHash.Schedule schedule, final PqRegistryHash.Registry r) {
return new NodeConfiguration() {
@Override
public boolean satisfiesAt(final long height) {
return PqRegistryHash.matchesAt(schedule, r, height, CHAIN_ID);
}
@Override
public String describe() {
return "node holding only registry " + name;
}
};
}
// ---------------------------------------------------------------------------------------
// Fixture. Two registries that differ in exactly one row, which is what both a rotation and a
// revocation look like on the wire: index 3 stops being the key it was.
// ---------------------------------------------------------------------------------------
private static byte[] deterministicKey(final int index, final int generation) {
final byte[] pk = new byte[PK_LENGTH];
for (int i = 0; i < pk.length; i++) {
pk[i] = (byte) ((i * 31) + (index * 7) + (generation * 101));
}
return pk;
}
private static byte[] address(final int index) {
final byte[] a = new byte[20];
for (int i = 0; i < a.length; i++) {
a[i] = (byte) ((index * 17) + i);
}
return a;
}
private static String hex(final byte[] b) {
final StringBuilder sb = new StringBuilder(b.length * 2);
for (final byte x : b) {
sb.append(String.format("%02x", x));
}
return sb.toString();
}
/**
* A seven-row address-bound registry. {@code rotatedIndex} is the row whose key belongs to
* generation 2; every other row is generation 1. Passing -1 gives the untouched registry.
*/
private static Path writeRegistry(final Path dir, final String name, final int rotatedIndex)
throws IOException {
final StringBuilder sb = new StringBuilder();
sb.append("count=").append(N).append('\n');
for (int i = 0; i < N; i++) {
sb.append(i).append('=').append(hex(deterministicKey(i, i == rotatedIndex ? 2 : 1))).append('\n');
sb.append(i).append(".addr=").append(hex(address(i))).append('\n');
}
final Path p = dir.resolve(name);
Files.write(p, sb.toString().getBytes(StandardCharsets.UTF_8));
return p;
}
private static PqRegistryHash.Schedule scheduleOf(final String hashAtH1, final String hashAtH2) {
final String json =
"[{\"block\":"
+ H1
+ ",\"hash\":\"0x"
+ hashAtH1
+ "\"},{\"block\":"
+ H2
+ ",\"hash\":\"0x"
+ hashAtH2
+ "\"}]";
final JsonNode node;
try {
node = new ObjectMapper().readTree(json);
} catch (final IOException e) {
throw new IllegalStateException(e);
}
return PqRegistryHash.parseSchedule(node, "rotation fixture");
}
/** Every height at which the binding is enforced and could differ across the rotation. */
private static List<Long> enforcedHeights() {
final List<Long> heights = new ArrayList<>();
heights.add(H1);
heights.add(H1 + 1);
heights.add(H2 - 1);
heights.add(H2);
heights.add(H2 + 1);
return heights;
}
/**
* The configurations a node can ACTUALLY be put into with the code as it stands. A repair that
* gives a node the whole scheduled history appends its configuration here; nothing else changes.
*/
private static List<NodeConfiguration> availableConfigurations(
final PqRegistryHash.Schedule schedule,
final PqRegistryHash.Registry before,
final PqRegistryHash.Registry after) {
final List<NodeConfiguration> all = new ArrayList<>();
all.add(single("BEFORE", schedule, before));
all.add(single("AFTER", schedule, after));
all.add(wholeHistory(schedule, before, after));
return all;
}
/**
* 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<PqRegistryHash.Registry> held = new ArrayList<>();
held.add(before);
held.add(after);
final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID);
return new NodeConfiguration() {
@Override
public boolean satisfiesAt(final long height) {
return PqRegistryHash.matchesAt(schedule, set, height, CHAIN_ID);
}
@Override
public String describe() {
return "node holding the whole scheduled history (" + set + ")";
}
};
}
// ---------------------------------------------------------------------------------------
// Controls. If these fail, the fixture is wrong and the measurements below mean nothing.
// ---------------------------------------------------------------------------------------
@Test
public void controlTheScheduleReallyDoesExpressARotation(@TempDir final Path dir)
throws IOException {
final PqRegistryHash.Registry before =
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
final PqRegistryHash.Registry after =
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
final String hashBefore = PqRegistryHash.hashV1(before, CHAIN_ID);
final String hashAfter = PqRegistryHash.hashV1(after, CHAIN_ID);
assertThat(hashBefore).isNotEqualTo(hashAfter);
final PqRegistryHash.Schedule schedule = scheduleOf(hashBefore, hashAfter);
assertThat(schedule.enforced()).isTrue();
assertThat(schedule.entries()).hasSize(2);
// Below the first entry nothing is bound: the 11.8 million existing blocks stay untouched.
assertThat(PqRegistryHash.requiredHashAt(schedule, H1 - 1)).isEmpty();
assertThat(PqRegistryHash.requiredHashAt(schedule, H1).orElseThrow().hash()).isEqualTo(hashBefore);
assertThat(PqRegistryHash.requiredHashAt(schedule, H2 - 1).orElseThrow().hash())
.isEqualTo(hashBefore);
assertThat(PqRegistryHash.requiredHashAt(schedule, H2).orElseThrow().hash()).isEqualTo(hashAfter);
}
@Test
public void controlAMalformedScheduleIsRefused(@TempDir final Path dir) throws IOException {
final PqRegistryHash.Registry before =
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
final String h = PqRegistryHash.hashV1(before, CHAIN_ID);
final String json =
"[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]";
final JsonNode node = new ObjectMapper().readTree(json);
assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "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<Long> heights = enforcedHeights();
final List<NodeConfiguration> configurations =
availableConfigurations(schedule, before, after);
final List<String> report = new ArrayList<>();
NodeConfiguration complete = null;
for (final NodeConfiguration c : configurations) {
final List<Long> refused = new ArrayList<>();
for (final long h : heights) {
if (!c.satisfiesAt(h)) {
refused.add(h);
}
}
report.add(c.describe() + " is refused at " + refused);
if (refused.isEmpty()) {
complete = c;
}
}
assertThat(complete)
.withFailMessage(
"ROTATION 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<String> report = new ArrayList<>();
for (final NodeConfiguration c : availableConfigurations(schedule, withCompromised, revoked)) {
boolean all = true;
final List<Long> refused = new ArrayList<>();
for (final long h : enforcedHeights()) {
if (!c.satisfiesAt(h)) {
all = false;
refused.add(h);
}
}
report.add(c.describe() + " is refused at " + refused);
if (all) {
complete = c;
}
}
assertThat(complete)
.withFailMessage(
"REVOCATION 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<PqRegistryHash.Registry> held = new ArrayList<>();
held.add(before);
held.add(after);
final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID);
assertThat(set.coversWholeSchedule()).isTrue();
assertThat(PqRegistryHash.registryAt(schedule, set, H1 - 1)).isEmpty();
assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow()).isSameAs(before);
assertThat(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow()).isSameAs(after);
// And the two really do differ at the rotated index, so "same registry everywhere" could not
// have produced the answers above.
assertThat(PqRegistryHash.fingerprint(before, 3))
.isNotEqualTo(PqRegistryHash.fingerprint(after, 3));
assertThat(PqRegistryHash.fingerprint(before, 0)).isEqualTo(PqRegistryHash.fingerprint(after, 0));
}
@Test
public void aMissingHistoricalRegistryIsNamedAndFailsClosed(@TempDir final Path dir)
throws IOException {
// The repair must not turn "I do not hold that registry" into "fine". An uncovered entry is
// named by height and refuses at exactly the heights it governs, and nowhere else.
final PqRegistryHash.Registry before =
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
final PqRegistryHash.Registry after =
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
final PqRegistryHash.Schedule schedule =
scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
final List<PqRegistryHash.Registry> onlyAfter = new ArrayList<>();
onlyAfter.add(after);
final PqRegistryHash.RegistrySet partial =
PqRegistryHash.buildSet(schedule, onlyAfter, CHAIN_ID);
assertThat(partial.coversWholeSchedule()).isFalse();
assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1);
assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse();
assertThat(PqRegistryHash.matchesAt(schedule, partial, H2 - 1, CHAIN_ID)).isFalse();
assertThat(PqRegistryHash.matchesAt(schedule, partial, H2, CHAIN_ID)).isTrue();
// Below the schedule nothing is enforced, so an incomplete set still leaves history alone.
assertThat(PqRegistryHash.matchesAt(schedule, partial, H1 - 1, CHAIN_ID)).isTrue();
// And a null set is refused wherever a binding is active, never passed over.
assertThat(PqRegistryHash.matchesAt(schedule, (PqRegistryHash.RegistrySet) null, H1, CHAIN_ID))
.isFalse();
}
@Test
public void theOperatorConfigurationStringProducesACoveringSet(@TempDir final Path dir)
throws IOException {
// WHY THIS EXISTS, and it is a gap the other six leave open on purpose-by-omission. Every one of
// them reaches the covering configuration by calling PqRegistryHash.buildSet with a list of
// Registry objects the test built itself. No operator can do that. What an operator can do is
// write a comma-separated list of FILE PATHS into aere.falcon.registry.history, and the node
// turns that string into the same set through parseRegistryPaths + loadAuto
// (FalconSealSupport.verifyRegistryBindingOrAbort, the 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<Path> paths = PqRegistryHash.parseRegistryPaths(configured);
assertThat(paths).hasSize(2);
final PqRegistryHash.RegistrySet set = PqRegistryHash.loadSet(schedule, paths, CHAIN_ID);
assertThat(set.coversWholeSchedule()).isTrue();
assertThat(set.uncoveredEntryBlocks()).isEmpty();
final List<Long> refused = new ArrayList<>();
for (final long h : enforcedHeights()) {
if (!PqRegistryHash.matchesAt(schedule, set, h, CHAIN_ID)) {
refused.add(h);
}
}
assertThat(refused)
.withFailMessage(
"ROTATION 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();
}
}

View File

@ -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.
*
* <p>MEASURED ON A NETWORK FIRST, NOT ASSUMED. With the anchor armed at K&gt;0, a SIMULTANEOUS
* restart of all seven validators kills the chain permanently, 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.
*
* <p>THE SECOND CIRCLE. The Falcon seals over M(head) travel on nothing but the Commit messages of
* the head block, and those are never replayed after a restart. They exist nowhere else: the head's
* own header carries a certificate over its PARENT, not over itself. So every node came back holding
* zero seals, none could reach K, none could propose, and therefore none ever sent another Commit
* for another node to hear. Seals come from Commits, Commits come from proposals, proposals need
* seals.
*
* <p>WHAT THIS CLASS MEASURES, one test per link, with the causal chain driven in BOTH directions so
* that "refuses" is never satisfied by a producer that always refuses:
*
* <ol>
* <li>{@link #restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut()} - the whole thing
* end to end: the same node, the same head, the same K. Without the file the producer throws;
* with the file restored it produces a K-seal certificate whose digest matches. This is the
* chain death and its exit, in one method.
* <li>{@link #aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache()} - THE SECURITY
* PROPERTY, and the test the build-time negative control turns RED. Three shapes of forgery in
* one file: a signature over the wrong message, random bytes, and a genuine seal re-labelled
* under someone else's index. None survives, and the genuine ones alongside them do.
* <li>{@link #aCorruptFileDoesNotStopTheNode()} - truncated, random, empty, a directory where the
* file should be. Every one of them yields an empty cache and no exception.
* <li>{@link #aFileFromAnotherHeightOrAnotherChainIsIgnored()} - the binding checks, before a
* single signature is verified.
* <li>{@link #theWriteIsAtomicUnderAConcurrentReader()} - a reader hammering the file across 120
* writes never observes a partial file.
* <li>{@link #thePathComesFromTheDataDirectory()} - the path is derived, never configured.
* <li>{@link #whatOneWriteCostsAgainstTheBlockInterval()} - the price of doing this on the
* consensus thread, as a number rather than as a hope.
* </ol>
*
* <p>NOT MEASURED here, and named so it is not read as covered: that seven live nodes recover from a
* real simultaneous restart with a binary built from this tree. That needs the rehearsal network and
* is separate evidence. This class measures every decision that recovery depends on.
*/
public class 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<Address> validators = new ArrayList<>();
private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
private BlockHeader head;
private ProtocolContext context;
private BftExtraData base;
@BeforeEach
public void setUp() throws Exception {
dataDirectory = Files.createDirectories(tmp.resolve("besu-data"));
// 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.
*
* <p>Three shapes of forgery are in the one file, because "a forged seal" is not one thing:
*
* <ol>
* <li>index 3, a REAL Falcon signature by validator 3, but over another block's M. This is the
* replay an attacker with access to any past Commit traffic actually has.
* <li>index 4, random bytes of exactly the right length. The cheapest forgery there is.
* <li>index 5, validator 0's GENUINE signature over the right M, re-labelled as index 5. This
* one is the reason index alone can never be the check: the bytes are valid, the claim is
* not.
* </ol>
*/
@Test
public void aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache() throws Exception {
final List<FalconSeal> genuine = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2));
final Bytes32 anotherBlocksMessage =
PqAnchor.commitMessage(CHAIN_ID, head.getNumber() - 1L, Bytes32.leftPad(Bytes.of(9)));
final byte[] randomBytes = new byte[genuine.get(0).getSignature().size()];
SecureRandomProvider.createSecureRandom().nextBytes(randomBytes);
final List<FalconSeal> forged =
List.of(
new FalconSeal(3, Bytes.wrap(falconSign(privateKeys.get(3), anotherBlocksMessage))),
new FalconSeal(4, Bytes.wrap(randomBytes)),
new FalconSeal(5, genuine.get(0).getSignature()));
final List<FalconSeal> all = new ArrayList<>(genuine);
all.addAll(forged);
final Path file = PqSealStore.fileIn(dataDirectory);
PqSealStore.writeAtomically(
file,
PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), all));
assertThat(file).exists();
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
final int restored =
PqSealCache.instance()
.restoreFromDisk(
head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport());
assertThat(restored)
.describedAs(
"THE LOAD-BEARING ASSERTION. Six seals were in the file and only the three genuine ones "
+ "may come out. Delete the verify() call in PqSealStore and this line goes red, "
+ "which is exactly what the build-time negative control proves.")
.isEqualTo(3);
final List<FalconSeal> inCache =
PqSealCache.instance().sealsFor(head.getNumber(), head.getHash());
assertThat(inCache).hasSize(3);
assertThat(inCache.stream().map(FalconSeal::getValidatorIndex))
.describedAs("no forged index may reach the cache at all")
.containsExactly(0, 1, 2);
assertThat(inCache).containsExactlyInAnyOrderElementsOf(genuine);
// And the genuine ones are not merely present, they are usable: the producer, which verifies
// again at selection, accepts exactly these three. Without this half the test would be
// satisfied by a reader that rejected everything.
final BftExtraData produced = PqAnchorProducer.apply(base, head, context);
assertThat(produced.getFalconSeals()).hasSize(K).containsExactlyElementsOf(genuine);
}
// -------------------------------------------------------------------------------------------
// 3. A corrupt file must never be able to stop a node.
// -------------------------------------------------------------------------------------------
@Test
public void aCorruptFileDoesNotStopTheNode() throws Exception {
final Path file = PqSealStore.fileIn(dataDirectory);
final byte[] good =
PqSealStore.encode(
CHAIN_ID,
head.getNumber(),
head.getHash().getBytes(),
List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2)));
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
// (a) no file at all: the ordinary first start.
Files.deleteIfExists(file);
assertRestoresNothingWithoutThrowing("no file at all");
// (b) truncated halfway: a write interrupted by a machine that lost power. The atomic rename
// is what stops this from happening, and this is what would happen if it did anyway.
Files.write(file, Arrays.copyOf(good, good.length / 2));
assertRestoresNothingWithoutThrowing("truncated file");
// (c) random bytes: a wrong file copied over it, or a corrupt sector.
final byte[] noise = new byte[good.length];
SecureRandomProvider.createSecureRandom().nextBytes(noise);
Files.write(file, noise);
assertRestoresNothingWithoutThrowing("random bytes");
// (d) empty file.
Files.write(file, new byte[0]);
assertRestoresNothingWithoutThrowing("empty file");
// (e) valid RLP, wrong domain: a file written for something else entirely.
Files.write(file, Bytes.fromHexString("0xc50102030405").toArrayUnsafe());
assertRestoresNothingWithoutThrowing("valid RLP, wrong shape");
// (f) a DIRECTORY where the file should be. Not exotic: a mount gone wrong does this.
Files.deleteIfExists(file);
Files.createDirectory(file);
assertRestoresNothingWithoutThrowing("a directory in place of the file");
Files.delete(file);
// And after all of that the node is still a working node: a good file still restores.
Files.write(file, good);
assertThat(
PqSealCache.instance()
.restoreFromDisk(
head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()))
.describedAs(
"positive control: without this line every assertion above would be satisfied by a "
+ "reader that can never read anything")
.isEqualTo(3);
}
private void assertRestoresNothingWithoutThrowing(final String what) {
PqSealCache.instance().clear();
assertThatCode(
() ->
assertThat(
PqSealCache.instance()
.restoreFromDisk(
head.getNumber(),
head.getHash(),
PqSignerRegistry.falconSealSupport()))
.describedAs("%s must restore nothing", what)
.isZero())
.describedAs("%s must not throw: a node that cannot read the file is a node with none", what)
.doesNotThrowAnyException();
assertThat(PqSealCache.instance().sealCount(head.getHash())).isZero();
}
// -------------------------------------------------------------------------------------------
// 4. The binding checks, made before any signature is verified.
// -------------------------------------------------------------------------------------------
@Test
public void aFileFromAnotherHeightOrAnotherChainIsIgnored() throws Exception {
final Path file = PqSealStore.fileIn(dataDirectory);
final List<FalconSeal> seals = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2));
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
// Same seals, but the file claims another height. They cannot help the proposer of head+1.
PqSealStore.writeAtomically(
file,
PqSealStore.encode(CHAIN_ID, head.getNumber() - 1L, head.getHash().getBytes(), seals));
assertRestoresNothingWithoutThrowing("a file from another height");
// Same seals, another block hash at the right height: a fork of the same number.
PqSealStore.writeAtomically(
file,
PqSealStore.encode(
CHAIN_ID, head.getNumber(), Bytes32.leftPad(Bytes.of(7)), seals));
assertRestoresNothingWithoutThrowing("a file for another block at the same height");
// Another chain running the same binaries and possibly the same Falcon keys.
PqSealStore.writeAtomically(
file,
PqSealStore.encode(442_807L, head.getNumber(), head.getHash().getBytes(), seals));
assertRestoresNothingWithoutThrowing("a file from another chain");
// Positive control for this method: the same three seals, correctly bound, do restore.
PqSealStore.writeAtomically(
file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals));
PqSealCache.instance().clear();
assertThat(
PqSealCache.instance()
.restoreFromDisk(
head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()))
.isEqualTo(3);
}
// -------------------------------------------------------------------------------------------
// 5. Atomicity, measured against a reader rather than asserted from the API docs.
// -------------------------------------------------------------------------------------------
@Test
public void theWriteIsAtomicUnderAConcurrentReader() throws Exception {
final Path file = PqSealStore.fileIn(dataDirectory);
final List<FalconSeal> seals = new ArrayList<>();
for (int i = 0; i < N; i++) {
seals.add(genuineSeal(i));
}
PqSealStore.writeAtomically(
file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals));
final AtomicBoolean stop = new AtomicBoolean(false);
final AtomicInteger reads = new AtomicInteger();
final AtomicInteger partialReads = new AtomicInteger();
final Thread reader =
new Thread(
() -> {
while (!stop.get()) {
final List<FalconSeal> got =
PqSealStore.readVerified(
file,
CHAIN_ID,
head.getNumber(),
head.getHash(),
PqSignerRegistry.falconSealSupport());
reads.incrementAndGet();
if (got.isEmpty()) {
partialReads.incrementAndGet();
}
}
});
reader.setDaemon(true);
reader.start();
for (int round = 0; round < 120; round++) {
final List<FalconSeal> subset = seals.subList(0, 1 + (round % N));
PqSealStore.writeAtomically(
file,
PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), subset));
}
stop.set(true);
reader.join(30_000L);
assertThat(reads)
.describedAs("fixture control: the reader must actually have run")
.hasValueGreaterThan(0);
assertThat(partialReads)
.describedAs(
"%s reads across 120 writes and not one saw a half-written file. Temp plus rename is "
+ "the reason; writing in place would have produced partial reads here.",
reads.get())
.hasValue(0);
assertThat(dataDirectory.resolve(PqSealStore.TEMP_FILE_NAME))
.describedAs("the temporary file must not be left behind")
.doesNotExist();
}
// -------------------------------------------------------------------------------------------
// 6. The path is DERIVED from the data directory, never separately configured.
// -------------------------------------------------------------------------------------------
@Test
public void thePathComesFromTheDataDirectory() {
assertThat(PqSealStore.fileIn(dataDirectory))
.isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
assertThat(PqSealCache.instance().persistenceFile())
.isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
final Path other = tmp.resolve("another-node");
PqSealCache.instance().enablePersistence(other, CHAIN_ID);
assertThat(PqSealCache.instance().persistenceFile())
.describedAs("two nodes on one machine never share the file")
.isEqualTo(other.resolve(PqSealStore.FILE_NAME))
.isNotEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
// A null data directory leaves persistence off rather than inventing a path.
PqSealCache.instance().disablePersistence();
PqSealCache.instance().enablePersistence(null, CHAIN_ID);
assertThat(PqSealCache.instance().persistenceFile()).isNull();
PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(0)));
assertThat(PqSealCache.instance().sealCount(head.getHash()))
.describedAs("with persistence off the cache still works exactly as before")
.isEqualTo(1);
}
// -------------------------------------------------------------------------------------------
// 7. The price, as a number.
// -------------------------------------------------------------------------------------------
/**
* The write happens on the consensus thread, once per Commit heard, so its cost is a real
* property of this change and not a footnote. Seven seals is the whole fleet.
*/
@Test
public void whatOneWriteCostsAgainstTheBlockInterval() throws Exception {
final Path file = PqSealStore.fileIn(dataDirectory);
final List<FalconSeal> seals = new ArrayList<>();
for (int i = 0; i < N; i++) {
seals.add(genuineSeal(i));
}
final byte[] payload =
PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals);
final int rounds = 100;
final long[] micros = new long[rounds];
for (int i = 0; i < rounds; i++) {
final long t0 = System.nanoTime();
PqSealStore.writeAtomically(file, payload);
micros[i] = (System.nanoTime() - t0) / 1_000L;
}
Arrays.sort(micros);
final long median = micros[rounds / 2];
final long p95 = micros[(int) (rounds * 0.95)];
final long worst = micros[rounds - 1];
// Printed so the number lands in the test XML and can be quoted as a measurement rather than
// remembered as an impression.
System.out.println(
"AERE PERSISTENTA-SIGILII MEASURED: payload="
+ payload.length
+ " bytes for "
+ N
+ " seals; write median="
+ median
+ " us, p95="
+ p95
+ " us, worst="
+ worst
+ " us over "
+ rounds
+ " writes; fsync="
+ !"false".equalsIgnoreCase(System.getProperty(PqSealStore.PROPERTY_FSYNC))
+ "; block interval="
+ BLOCK_INTERVAL_MS
+ " ms.");
assertThat(payload.length)
.describedAs("seven Falcon-512 seals plus the binding fields")
.isLessThan(16 * 1024);
assertThat(median)
.describedAs(
"one write must cost far less than one block interval, or persisting on the consensus "
+ "thread would be trading a restart deadlock for a liveness cost")
.isLessThan(BLOCK_INTERVAL_MS * 1_000L / 10L);
}
// -------------------------------------------------------------------------------------------
// Helpers.
// -------------------------------------------------------------------------------------------
private FalconSeal genuineSeal(final int index) {
final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, head.getNumber(), head.getHash().getBytes());
return new FalconSeal(index, Bytes.wrap(falconSign(privateKeys.get(index), m)));
}
private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) {
final FalconSigner signer = new FalconSigner();
signer.init(true, key);
return signer.generateSignature(m.toArray());
}
private static ProtocolContext contextWith(final Collection<Address> validatorSet) {
final ValidatorProvider validatorProvider =
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validatorSet);
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validatorSet);
final BftContext bftContext =
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
when(bftContext.as(any())).thenReturn(bftContext);
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>THE RULE CHOSEN, and why it is the correct one rather than merely a working one:
*
* <ul>
* <li>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.
* <li>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()}.
* <li>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()}.
* <li>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()}.
* <li>An entry no held file reproduces stays UNCOVERED, which is a different condition with a
* different message. It is not compared - {@link #anUncoveredEntryIsNotAMisboundOne()}.
* </ul>
*
* <p>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.
*
* <p>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<Long, String> 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<Long, String> 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<Long, String> entries) throws IOException {
final List<Long> heights = new ArrayList<>(entries.keySet());
heights.sort(Long::compare);
final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
for (int i = 0; i < heights.size(); i++) {
if (i > 0) {
sb.append(',');
}
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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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<Long, String> 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<Long, String> 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<Long, String> 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<Long, String> 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<Long, String> entries) throws IOException {
final List<Long> heights = new ArrayList<>(entries.keySet());
heights.sort(Long::compare);
final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
for (int i = 0; i < heights.size(); i++) {
if (i > 0) {
sb.append(',');
}
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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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<Holder> 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<Holder> 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<Holder> 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<Holder> 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;
}
}

View File

@ -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<CommitPayload> {
/**
* Instantiates a new Commit.
*
* @param payload the payload
*/
public Commit(final SignedData<CommitPayload> 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<FalconSeal> 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));
}
}

View File

@ -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> blockAccessList,
final List<SignedData<RoundChangePayload>> roundChanges,
final List<SignedData<PreparePayload>> 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> 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<PreparedCertificate> 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());
}
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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> 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> 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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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> 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<FalconSeal> 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();
}
}

View File

@ -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> blockAccessList,
final List<SignedData<RoundChangePayload>> roundChanges,
final List<SignedData<PreparePayload>> 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<SignedData<RoundChangePayload>> roundChanges,
final List<SignedData<PreparePayload>> 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> 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<PreparedCertificate> 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 <M extends Payload> SignedData<M> createSignedMessage(final M payload) {
final SECPSignature signature =
nodeKey.sign(Bytes32.wrap(payload.hashForSignature().getBytes()));
return SignedData.create(payload, signature);
}
}

View File

@ -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<Integer, RoundState> futureRoundStateBuffer = Maps.newHashMap();
private final FutureRoundProposalMessageValidator futureRoundProposalMessageValidator;
private final Clock clock;
private final Function<ConsensusRoundIdentifier, RoundState> roundStateCreator;
private final QbftFinalState finalState;
private Optional<PreparedCertificate> latestPreparedCertificate = Optional.empty();
private Optional<QbftRound> 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> 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<Address> previousValidators =
validatorProvider.getValidatorsForBlock(parentHeader);
final Collection<Address> 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> 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 <P extends Payload, M extends BftMessage<P>> void actionOrBufferMessage(
final M qbftMessage,
final Consumer<M> inRoundHandler,
final BiConsumer<RoundState, M> 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<Collection<RoundChange>> 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<Integer> 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<QbftRound> getCurrentRound() {
return currentRound;
}
@Override
public Optional<RoundChangeManager> 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
}
}

View File

@ -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<QbftMinedBlockObserver> 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<QbftMinedBlockObserver> 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<PreparedCertificate> bestPreparedCertificate =
roundChangeArtifacts.getBestPreparedPeer();
final QbftBlock blockToPublish;
final Optional<BlockAccessList> 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> blockAccessList,
final List<SignedData<RoundChangePayload>> roundChanges,
final List<SignedData<PreparePayload>> 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<PreparedCertificate> 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> 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> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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));
}
}

View File

@ -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<Proposal> 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<Address, Prepare> prepareMessages = new LinkedHashMap<>();
private final Map<Address, Commit> 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<QbftBlock> getProposedBlock() {
return proposalMessage.map(p -> p.getSignedPayload().getPayload().getProposedBlock());
}
/**
* Gets proposed block access list.
*
* @return the block access list
*/
public Optional<BlockAccessList> 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<SECPSignature> 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<FalconSeal> 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<PreparedCertificate> 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();
}
}

View File

@ -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> 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<SECPSignature> 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<SECPSignature> commitSeals,
final Collection<FalconSeal> falconSeals) {
return createSealedBlock(block, roundNumber, commitSeals);
}
/**
* Convenience empty Falcon seal collection.
*
* @return an empty collection of Falcon seals
*/
static Collection<FalconSeal> noFalconSeals() {
return Collections.emptyList();
}
}

View File

@ -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> baseFeeMarket) {
return blockHeaderValidator(
minimumTimeBetweenBlocks,
useValidatorContract,
baseFeeMarket,
PqAnchorConfig.fromSystemConfiguration());
}
/**
* Produces a BlockHeaderValidator configured for assessing bft block headers, with an explicit V2
* certificate-anchor configuration.
*
* <p>AERE ANCORA-V2 (2026-08-01). Two rules are added and one is retired, all indexed on the BLOCK
* NUMBER:
*
* <ul>
* <li>{@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.
* <li>{@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).
* <li>{@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.
* </ul>
*
* <p>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.
*
* <p>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> 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;
}
}

View File

@ -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.
*
* <p>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<VoteType, Byte> 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<Address> 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<Address> 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<Address> validators = rlpInput.readList(Address::readFrom);
final Optional<Vote> 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<SECPSignature> 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<FalconSeal> 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);
}
}

View File

@ -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<SECPSignature> commitSeals) {
return createSealedBlock(block, roundNumber, commitSeals, Collections.emptyList());
}
@Override
public QbftBlock createSealedBlock(
final QbftBlock block,
final int roundNumber,
final Collection<SECPSignature> commitSeals,
final Collection<FalconSeal> 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<Address> registered = pqc.registeredValidatorAddresses();
final Set<Address> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> verifiedDistinctSeals(
final FalconSealSupport pqc,
final long blockNumber,
final Hash commitHash,
final Collection<FalconSeal> seals,
final Set<Address> eligible,
final OptionalInt cap) {
final List<FalconSeal> out = new ArrayList<>();
final Set<Address> 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;
}
}

View File

@ -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<QbftConfigOptions> {
/**
* 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<QbftConfigOptions> 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.
*
* <p>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}.
*
* <p>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));
}
}

View File

@ -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.
*
* <p>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.
*
* <p><b>AERE audit fix, ELIGIBLE-SIGNER BINDING (2026-07-18).</b> Both the Falcon quorum threshold
* AND the counted-seal set are now bound to ONE well-defined set:
*
* <pre>
* 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
* </pre>
*
* <p>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).
*
* <p><b>ECDSA stays decisive.</b> 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.
*
* <p>Behaviour is fork-gated by {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}:
*
* <ul>
* <li>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.
* <li>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.
* </ul>
*
* <p>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.
*
* <p>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.
*
* <p>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 <b>2295 lines in twenty minutes, about 165.000
* a day per node</b>, 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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>{@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.
*
* <p>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.
*
* <p>Two reasons to log at INFO, and no others:
*
* <ul>
* <li>the outcome CHANGED since the last INFO, so an operator sees every transition on the block
* it happens
* <li>{@link #LOG_HEARTBEAT_BLOCKS} have passed, so the line never vanishes from a quiet log
* </ul>
*
* <p>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<FalconSeal> falconSeals = extraData.getFalconSeals();
final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class);
final Collection<Address> 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<Address> registered = pqc.registeredValidatorAddresses();
final Set<Address> 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<Address> 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<WorldState> 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)
+ ")";
}
}

View File

@ -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.
*
* <p><b>Why a second copy of a rule that already exists.</b> {@link PqAnchorDigestRule} is DETACHED.
* Besu decides which rules run from the {@code HeaderValidationMode}, and the two facts that matter
* were measured, not assumed:
*
* <ul>
* <li>{@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.
* <li>{@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.
* </ul>
*
* <p>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.
*
* <p><b>It is the same verdict, not a second opinion.</b> 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.
*
* <p><b>Deliberately NOT in light validation.</b> {@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.
*
* <p><b>Inactive below H.</b> 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")
+ ")";
}
}

View File

@ -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.
*
* <p>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.
*
* <p><b>Pure function of the header.</b> 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.
*
* <p><b>WHERE IT ACTUALLY RUNS. MEASURED 2026-08-02, and this paragraph CORRECTS an earlier claim in
* this same file.</b> 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:
*
* <ul>
* <li>{@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}.]
* <li>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.]
* <li>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.]
* </ul>
*
* <p>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.]
*
* <p><b>includeInLightValidation, measured.</b> 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.
*
* <p><b>Inactive below H.</b> 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.
*
* <p><b>Fail-closed above H.</b> 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.
*
* <p>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<FalconSeal> 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")
+ ")";
}
}

View File

@ -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.
*
* <p>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.
*
* <ol>
* <li>k = |C| is at least K(height), the staged threshold;
* <li>the validator indices are STRICTLY increasing, so there is one accepted order and a repeated
* index is unrepresentable;
* <li>every index maps, through the registry, to an address in the PARENT's validator set;
* <li>every signature verifies as Falcon-512 over M(parent).
* </ol>
*
* <p><b>Why the "what I heard" comparison is absent, and why that is the entire point.</b> 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.
*
* <p><b>Not in light validation.</b> 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.
*
* <p><b>Inactive below H</b>, checked as the first statement, before any decode.
*
* <p><b>Fail-closed above H</b>, on every path including exceptions and including a missing or
* mismatched parent.
*
* <p>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&gt;=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<FalconSeal> 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<Address> 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<Address> eligible = new HashSet<>(parentValidators);
final Bytes32 message =
PqAnchor.commitMessage(
config.chainId(), parent.getNumber(), parent.getHash().getBytes());
final Set<Address> 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<Address> 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
+ ")";
}
}

View File

@ -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.
*
* <p><b>The rule this exists to obey.</b> 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.
*
* <p><b>Why a header rule.</b> 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.
*
* <p><b>It cannot reject anything.</b> {@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.
*
* <p><b>It says nothing when nothing is overridden.</b> 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.
*
* <p>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;
}
}

View File

@ -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.
*
* <p><b>The hole this closes, stated as the measurement that found it.</b> 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."
*
* <p><b>Why a header rule and not a background timer.</b> 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.
*
* <p><b>What it does on mismatch, and why refusing is the fail-closed direction.</b> 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.
*
* <p><b>Inert unless somebody armed it, three times over.</b> 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.
*
* <p><b>DETACHED. And the light-validation flag does NOT keep it off the sync path - that claim
* was here and it was wrong.</b> 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.
*
* <p>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.
*
* <p>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;
}
}

View File

@ -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<String> 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<String> rulesInside(final Object validator) throws Exception {
final List<String> 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.
*
* <p>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<String> 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<String> 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<String> 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<String> 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);
}
}

View File

@ -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.
*
* <p>MEASURED ON A LIVE NODE, and that is why this test exists. The rule wrote the summary at INFO
* on every imported block: <b>2295 lines in twenty minutes</b>, 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.
*
* <p>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<Runnable> 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);
}
}

View File

@ -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.
*
* <p>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());
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<FalconSeal> 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<FalconSeal> subsetA = List.of(seal(0), seal(1), seal(2), seal(3), seal(4));
final List<FalconSeal> 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<FalconSeal> original = List.of(seal(0), seal(1), seal(2));
final List<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<Integer> 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<FalconSeal> 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<FalconSeal> 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<Address> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> 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<FalconSeal> sealsFor(final BlockHeader parent, final List<Integer> indices) {
final Bytes32 message = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
final List<FalconSeal> 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<Integer> indices) {
final List<FalconSeal> 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<Address> 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<Long, Map<Integer, Address>> 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<Long, Integer> generations = new TreeMap<>();
private FakeRegistry() {
final Map<Integer, Address> 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<Integer, Address> 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<Integer, Address> 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<Integer> signers = List.of(0, 1, 2, 3, 4);
for (int position = 0; position < signers.size(); position++) {
final List<FalconSeal> 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();
}
}

View File

@ -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<Address> 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<FalconSeal> 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<FalconSeal> certificate) {
final List<FalconSeal> 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();
}
}

View File

@ -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.
*
* <p>{@code FalconSealSupport.validateAnchorObservationHeightOrAbort} refuses to start a node whose
* blocking height is armed at or before the height at which its registry can become active. That
* closes the misconfiguration. It cannot close the ACCIDENT: an operator declares the observation
* height correctly, the anchor-deploy transaction does not land, and the blocking height arrives
* over an empty registry anyway.
*
* <p>{@link FalconSealValidationRule} answers that by staying LOG-ONLY, and that answer is right -
* blocking over an empty registry buys no safety and costs the chain. It is also exactly how the
* condition used to disappear: the node was configured to enforce a post-quantum quorum, enforced
* nothing, and said so in a warning that nothing reads and no command can exit on.
*
* <p>These three tests measure the mark it now leaves, in both directions.
*/
public class 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<Address> validators) {
final ValidatorProvider validatorProvider =
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
final BftContext bftContext =
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
when(bftContext.as(any())).thenReturn(bftContext);
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
}
private static void forgetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -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.
*
* <p>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();
}
}

View File

@ -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.
*
* <p>The gate's registry-coverage report reads a validator set recorded by {@code
* FalconSealSupport.observeValidators}. Until 2026-08-02 the ONLY caller of that method was {@link
* FalconSealValidationRule}, and {@link PqAnchorConfig#legacyFalconRuleRetirementBlock()} stands
* that rule down at exactly the anchor height H. So from H upward nothing fed it: the recorded set
* was either frozen at a height below H, or - on any node whose process started above H - never
* recorded at all. The old gate answered "never recorded" by switching seal attachment off, which
* is one restart away from a chain that cannot propose.
*
* <p>This class measures the WIRING, in both directions, and it is the only thing that separates the
* repair from a claim about it:
*
* <ul>
* <li>the legacy rule really does stand down at H, so it really cannot be the feed above H;
* <li>{@link PqAnchorSealsRule}, which runs at every height from H, really does feed it.
* </ul>
*/
public class 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<Address> validators) {
final ValidatorProvider validatorProvider =
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
final BftContext bftContext =
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
when(bftContext.as(any())).thenReturn(bftContext);
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
}
private static void forgetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
/** A registry that binds nothing: this file measures the feed, never the verification. */
private static final class NoRegistry
implements org.hyperledger.besu.consensus.common.bft.PqSignerRegistry {
// 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";
}
}
}

View File

@ -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<SyncTargetRange, CompletableFuture<RangeHeaders>> {
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<RangeHeaders> apply(final SyncTargetRange checkpointRange) {
final CompletableFuture<List<BlockHeader>> taskFuture = downloadHeaders(checkpointRange);
final CompletableFuture<RangeHeaders> processedFuture =
taskFuture.thenApply(headers -> processHeaders(checkpointRange, headers));
FutureUtils.propagateCancellation(processedFuture, taskFuture);
return processedFuture;
}
private CompletableFuture<List<BlockHeader>> 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<List<BlockHeader>> 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<BlockHeader> 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.
*
* <p>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.
*
* <p>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.
*
* <p>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<BlockHeader> 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<BlockHeader> headers) {
if (checkpointRange.hasEnd()) {
final List<BlockHeader> headersToImport = new ArrayList<>(headers);
headersToImport.add(checkpointRange.getEnd());
return new RangeHeaders(checkpointRange, headersToImport);
} else {
List<BlockHeader> headersToImport = headers;
if (!headers.isEmpty() && headers.getFirst().equals(checkpointRange.getStart())) {
headersToImport = headers.subList(1, headers.size());
}
return new RangeHeaders(checkpointRange, headersToImport);
}
}
}