diff --git a/IMPORT-PROOF-STARE-2026-08-15.md b/IMPORT-PROOF-STARE-2026-08-15.md index 3a0e878..b4e4bb3 100644 --- a/IMPORT-PROOF-STARE-2026-08-15.md +++ b/IMPORT-PROOF-STARE-2026-08-15.md @@ -101,3 +101,42 @@ istorice de ancorare dezarmata**, numite cu margini exacte, in care regulile de antetul asa cum e, exact ca ferestrele de exceptie ale pragului de taxa pe care al doilea client le poarta deja. Reparatia e in lucru; pana e dovedita prin import dincolo de fereastra, aceasta sectiune spune limita in loc sa o ascunda. + + +## DOVADA COMPLETA: nodul din pachete a ajuns la varf, 16 august + +**Un nod construit numai din pachetele publice a importat lantul 2800 de la blocul 0 pana la +varful viu, si acum il urmareste.** Ultima inaltime atinsa in aceasta rulare: **14.088.211**, si nodul raporta ca nu mai sincronizeaza, adica prinsese varful. **Zero respingeri de ancora, zero esecuri de import, zero nepotriviri de +stare.** A trecut de fereastra dezarmata, de inaltimea consensului blocant complet, si a prins +varful. + +Ca sa ajunga acolo au trebuit doua lucruri, si al doilea nu era in ipoteza initiala. + +**Intai, fereastra in care ancorarea a fost dezarmata**, descrisa mai sus: 36 de inaltimi de +ancora, 13.267.824 pana la 13.268.944, in care blocurile poarta certificat atasat dar nu poarta +digest, si in care indicii semnatarilor nu cresc strict. Rezolvata cu o lista de ferestre +istorice numite, cu margini fixe in cod, dupa modelul ferestrelor de exceptie ale pragului de +taxa: inauntru antetul se accepta asa cum e, in afara nu se schimba absolut nimic. + +**Apoi, recuperarea de dupa incident**, gasita masurand, nu ghicind. Dupa rearmarea de urgenta +flota a mers o vreme cu pragul de sigilii coborat, deci propunatorii au scris legitim certificate +mai scurte. Intinderea ei s-a masurat citind **25.252 de inaltimi de ancora una cate una**, fara +cautare binara, fiindca proprietatea nu e monotona: doar ~15 la suta din inaltimi sunt atinse si +cea mai mare distanta intre doua atinse e de 44 de inaltimi, deci o cautare binara ar fi dat un +raspuns care arata exact ca unul bun. Rezultatul: **13.268.976 pana la 13.890.544**, 19.425 de +inaltimi, cu praguri masurate de 1 sau 2, niciodata 0. + +**Relaxarea celei de-a doua ferestre atinge NUMAI numarul de sigilii.** Legarea digestului si +ordonarea indicilor raman impuse, si fiecare sigiliu e in continuare verificat. Alternativa +lenesa, largirea ferestrei dezarmate ca sa le acopere pe amandoua, ar fi aruncat legarea +certificatului pe 19.425 de inaltimi de ancora. Aceea ar fi fost o reparatie care sterge exact +proprietatea pentru care exista ancora. + +**Controlul negativ, in patru directii, fiindca o exceptie poate gresi in ambele sensuri:** +predicatul de fereastra fortat mereu fals inroseste 10 probe (ce e inauntru nu mai e acceptat); +fortat mereu adevarat inroseste 39, dintre care 22 sunt probe de strictete care existau dinainte, +adica se vede negru pe alb exceptia inghitind lantul; pragul istoric golit inroseste 7; pragul +istoric fixat la 1 inroseste 8. Repus, totul verde: **641 de probe, 0 esecuri**, numarate din XML. + +Peticul publicat poarta amandoua ferestrele si a fost verificat pe un checkout pristin al +amontelui, singur si in serie, iar arborele rezultat compileaza. diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java index a383bcf..59c2d86 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java @@ -249,11 +249,17 @@ public final class PqAnchor { * [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. + * + *

DERIVED, not written a second time. The bounds themselves live in {@link PqAnchorLapse}, + * which is the one place a historical window is named, because the SAME interruption that left + * these certificates unsorted also left their headers without an anchor digest, and a value that + * appears in two places diverges. This constant is kept because it is the name this class has + * always used for the first bound. */ - public static final long UNORDERED_WINDOW_FIRST = 13_267_824L; + public static final long UNORDERED_WINDOW_FIRST = PqAnchorLapse.windows().get(0).firstBlock(); /** 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; + public static final long UNORDERED_WINDOW_LAST = PqAnchorLapse.windows().get(0).lastBlock(); /** * Whether the certificate's indices are non-negative and pairwise DISTINCT, in any order. @@ -315,9 +321,7 @@ public final class PqAnchor { if (hasStrictlyIncreasingIndices(certificate)) { return true; } - final boolean inHistoricalWindow = - blockNumber >= UNORDERED_WINDOW_FIRST && blockNumber <= UNORDERED_WINDOW_LAST; - return inHistoricalWindow && hasDistinctNonNegativeIndices(certificate); + return PqAnchorLapse.isDisarmed(blockNumber) && hasDistinctNonNegativeIndices(certificate); } /** diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java new file mode 100644 index 0000000..a7c8c9b --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java @@ -0,0 +1,375 @@ +/* + * 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.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +/** + * The historical ranges on chain 2800 in which the anchor rules were not fully in force. + * + *

WHAT HAPPENED. On 2026-08-10 all nine validators were restarted inside a few minutes. A + * node that has just restarted holds no Falcon seals until it takes part in one commit, so for a + * moment every node held none; the very next height was an anchor height, its threshold asked for + * three seals, nobody could propose, and the chain stopped for nine minutes. The measure that + * restarted it was to switch the anchor rules off on all nine nodes at once, and then to restore + * them with the emergency seal ceiling lowered rather than at the scheduled threshold. That leaves + * TWO ranges of canonical headers, not one, and they fail a strict validator for different reasons: + * + *

    + *
  1. The rules off entirely. vanityData carries the client's usual version string instead + * of the anchor digest, although the certificate is attached, so the digest binding does not + * hold; and the certificate's validator indices are in the order the commit messages ARRIVED + * rather than sorted, so they are distinct but not increasing. + *
  2. The rules on, the threshold lowered. vanityData carries a correct digest and the + * indices are sorted, so both of those bind exactly as they should. What differs is the COUNT: + * a proposer running under the lowered ceiling legitimately wrote fewer seals than the + * configured schedule asks for, so the header is short of the threshold and nothing else. + *
+ * + *

WHY THE EXCEPTION BELONGS AT VALIDATION AND NOWHERE ELSE. Nodes that already hold these + * blocks never revalidate them, which is why the damage was invisible. A node built from this source + * and synced from genesis does validate them, and under the plain rules it stops at the first header + * of each range and never passes it. The history is what it is: no recomputation can turn these + * headers into headers that satisfy a rule they were written without. What a correct client can do + * is name the ranges and judge those headers by the rule that was actually in force when they were + * written, which is what every node holding the chain does today. A patch anywhere else moves the + * error instead of ending it. + * + *

THE BOUNDS, MEASURED, NOT SEARCHED. Neither property is monotone: inside the second + * range only about one anchor header in seven is short of the threshold and the rest are not, so a + * binary search would return a height that looks exactly like an answer and is not one. Every anchor + * height from the activation height 13,014,000 to the head at the time of measurement, 14,077,000, + * was therefore read one by one and its vanityData and certificate decoded: 25,252 heights, + * zero transport errors, and exactly the two contiguous ranges named below. Outside them there is + * not one header without a digest, not one with unsorted indices, and not one below the threshold. + * + *

WHAT THE RANGES DO NOT RELAX, AT ANY HEIGHT. Over the first range a certificate must + * still decode and must still carry non-negative, pairwise DISTINCT indices: sortedness fixes one + * accepted order for a given set, while distinctness is the part that is load bearing against an + * attacker, because it is what makes "repeat one seal to inflate the count" unrepresentable in the + * grammar of the format. Over the second range NOTHING is relaxed except the count, and even the + * count keeps a floor: the lowest number of seals measured anywhere in that range is + * 1, which is the ceiling that was actually in force, so that is the floor applied, + * not zero. Both ranges therefore admit exactly the history that exists and nothing weaker. + * + *

THE BOUNDS ARE FIXED IN CODE ON PURPOSE. A range a node could widen at runtime would let + * a future lapse pass unnoticed. Outside these ranges nothing changes at all, so a NEW unenforced + * header still stops this client, which is what should happen. Producers never consult this class: + * new headers are always written with the digest, with sorted indices and at the scheduled + * threshold, so the ranges are closed by construction and cannot grow. + * + *

THE TYPE IS A LIST BECAUSE A THIRD RANGE MUST NOT REQUIRE TOUCHING A RULE. The two + * entries below are the two the chain has. Adding another is an edit to this file alone. + */ +public final class PqAnchorLapse { + + /** The chain these ranges belong to. They describe no other chain. */ + public static final long CHAIN_ID = 2800L; + + /** The anchor spacing in force over both ranges, used by the self-check below. */ + public static final long ANCHOR_SPACING = 32L; + + /** What was not in force over a range. */ + public enum Relaxation { + /** Nothing was in force: no digest binding, no ordering, no threshold. */ + EVERYTHING, + /** The digest and the ordering were in force; only the seal threshold was lowered. */ + THRESHOLD_ONLY + } + + /** + * One contiguous range of heights over which the anchor rules were not fully in force. + * + *

Both bounds are INCLUSIVE and both are measured anchor heights: the first is the first header + * that shows the defect and the last is the last one that shows it. + */ + public static final class Window { + + private final long firstBlock; + private final long lastBlock; + private final long anchorHeights; + private final Relaxation relaxation; + private final int effectiveMinSeals; + private final String reason; + + /** + * Names a range. + * + * @param firstBlock the first affected height, inclusive + * @param lastBlock the last affected height, inclusive + * @param anchorHeights how many anchor heights the range spans; carried so that the count and + * the bounds are checked against each other rather than written twice + * @param relaxation what was not in force + * @param effectiveMinSeals the seal threshold that WAS in force over the range, which is the + * floor still applied inside it; zero when nothing was in force + * @param reason why the range exists, and what was measured + */ + public Window( + final long firstBlock, + final long lastBlock, + final long anchorHeights, + final Relaxation relaxation, + final int effectiveMinSeals, + final String reason) { + if (firstBlock < 0L || lastBlock < firstBlock) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR LAPSE: a window must be a non-negative, non-empty range, got [" + + firstBlock + + ", " + + lastBlock + + "]"); + } + if (anchorHeights < 1L) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR LAPSE: a window must span at least one anchor height, got " + + anchorHeights); + } + if (effectiveMinSeals < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR LAPSE: the threshold in force cannot be negative, got " + + effectiveMinSeals); + } + this.firstBlock = firstBlock; + this.lastBlock = lastBlock; + this.anchorHeights = anchorHeights; + this.relaxation = relaxation; + this.effectiveMinSeals = effectiveMinSeals; + this.reason = reason; + } + + /** + * The first affected height. + * + * @return the inclusive lower bound + */ + public long firstBlock() { + return firstBlock; + } + + /** + * The last affected height. + * + * @return the inclusive upper bound + */ + public long lastBlock() { + return lastBlock; + } + + /** + * How many anchor heights this range spans. + * + * @return the span, in anchor heights + */ + public long anchorHeights() { + return anchorHeights; + } + + /** + * What was not in force over this range. + * + * @return the relaxation + */ + public Relaxation relaxation() { + return relaxation; + } + + /** + * The seal threshold that WAS in force over this range. + * + * @return the measured floor, zero when no rule was in force + */ + public int effectiveMinSeals() { + return effectiveMinSeals; + } + + /** + * Why this range exists. + * + * @return the reason, with what was measured + */ + public String reason() { + return reason; + } + + /** + * Whether a height falls inside this range. + * + * @param blockNumber the height being judged + * @return true iff the height is within the inclusive bounds + */ + public boolean covers(final long blockNumber) { + return blockNumber >= firstBlock && blockNumber <= lastBlock; + } + + @Override + public String toString() { + return "[" + + firstBlock + + ", " + + lastBlock + + "] " + + relaxation + + " (" + + anchorHeights + + " anchor heights, threshold in force " + + effectiveMinSeals + + ")"; + } + } + + private static final List WINDOWS = + List.of( + new Window( + 13_267_824L, + 13_268_944L, + 36L, + Relaxation.EVERYTHING, + 0, + "2026-08-10: the anchor rules were switched off on the whole validator set to restart " + + "a stopped chain, so the headers produced meanwhile carry the client version " + + "string in vanityData instead of the anchor digest, and their certificates " + + "carry the indices in arrival order rather than sorted. Measured: 36 anchor " + + "heights, all 36 affected, all with distinct non-negative indices"), + new Window( + 13_268_976L, + 13_890_544L, + 19_425L, + Relaxation.THRESHOLD_ONLY, + 1, + "2026-08-10 onwards: the rules were restored immediately afterwards " + + "but with the emergency seal ceiling lowered, because raising the threshold " + + "back in one step had stopped the chain a second time. Every header here binds " + + "its digest and carries sorted indices; 2,926 of the 19,425 " + + "anchor heights in the range simply carry fewer seals than the schedule asks, " + + "the fewest being 1")); + + static { + // The list is ordered and disjoint, and each entry's stated span agrees with its own bounds. + // This runs at class initialisation so that a future entry which contradicts itself, or which + // overlaps its neighbour, fails loudly at startup rather than silently widening what a node + // accepts. + Window previous = null; + for (final Window window : WINDOWS) { + if (previous != null && window.firstBlock() <= previous.lastBlock()) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: windows must be ordered and disjoint, " + + previous + + " overlaps " + + window); + } + final long derived = (window.lastBlock() - window.firstBlock()) / ANCHOR_SPACING + 1L; + if (derived != window.anchorHeights()) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: window " + + window + + " says it spans " + + window.anchorHeights() + + " anchor heights, but its bounds at spacing " + + ANCHOR_SPACING + + " span " + + derived); + } + if (window.relaxation() == Relaxation.EVERYTHING && window.effectiveMinSeals() != 0) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: window " + window + " has no rule in force, so no threshold"); + } + if (window.relaxation() == Relaxation.THRESHOLD_ONLY && window.effectiveMinSeals() < 1) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: window " + + window + + " relaxes only the threshold, so the threshold it replaces it with must be at " + + "least 1; a floor of zero would accept an empty certificate where a certificate " + + "was in fact required"); + } + previous = window; + } + } + + private PqAnchorLapse() {} + + /** + * Every named range, ordered by first height. + * + * @return an unmodifiable list, never null, possibly empty + */ + public static List windows() { + return WINDOWS; + } + + /** + * Whether the anchor rules were out of force ENTIRELY at this height. + * + *

This is the single question both anchor rules ask before they judge anything. They ask it + * through this one method, for the same reason they share {@code anchorAppliesAt}: two rules that + * each decided the boundary their own way could disagree about one header, and a header accepted + * by one rule and refused by the other is a chain break. + * + *

It is deliberately FALSE inside a threshold-only range. There the digest binding and the + * ordering did hold and are still required; only {@link #historicMinSeals(long)} moves. + * + * @param blockNumber the height being judged + * @return true iff the height falls inside a range where nothing was in force + */ + public static boolean isDisarmed(final long blockNumber) { + for (final Window window : WINDOWS) { + if (window.covers(blockNumber) && window.relaxation() == Relaxation.EVERYTHING) { + return true; + } + } + return false; + } + + /** + * The seal threshold that was actually in force at this height, when it was lower than the + * configured schedule. + * + *

This is the same lever the fleet itself used at the time, the emergency ceiling, replayed + * from a fixed range instead of from a runtime option. A node validating history therefore asks + * the certificate for what was asked of the proposer that wrote it, and nothing more. + * + * @param blockNumber the height being judged + * @return the threshold in force, or empty when the configured schedule applies unchanged + */ + public static OptionalInt historicMinSeals(final long blockNumber) { + for (final Window window : WINDOWS) { + if (window.covers(blockNumber) && window.relaxation() == Relaxation.THRESHOLD_ONLY) { + return OptionalInt.of(window.effectiveMinSeals()); + } + } + return OptionalInt.empty(); + } + + /** + * The range covering a height, when there is one. + * + * @param blockNumber the height being judged + * @return the range, or empty + */ + public static Optional windowCovering(final long blockNumber) { + return WINDOWS.stream().filter(w -> w.covers(blockNumber)).findFirst(); + } + + /** + * The first height of the earliest named range, or empty when there is none. + * + * @return the earliest affected height + */ + public static Optional earliestAffectedBlock() { + return WINDOWS.stream().map(Window::firstBlock).min(Comparator.naturalOrder()); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java new file mode 100644 index 0000000..038d862 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java @@ -0,0 +1,249 @@ +/* + * 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.List; + +import org.junit.jupiter.api.Test; + +/** + * The named historical ranges in which the anchor rules were not fully in force. + * + *

The bounds are the whole content of the class under test, so they are asserted as LITERALS + * rather than through the constants they define. An assertion written as {@code + * assertThat(FIRST).isEqualTo(FIRST)} would follow any future edit of the constant and would + * therefore never fail, which is the exact shape of a gate that cannot go red. + */ +public class PqAnchorLapseTest { + + private static final long LAST_ENFORCED_BEFORE = 13_267_792L; + + private static final long DISARMED_FIRST = 13_267_824L; + private static final long DISARMED_LAST = 13_268_944L; + + private static final long LOWERED_FIRST = 13_268_976L; + private static final long LOWERED_LAST = 13_890_544L; + + private static final long FIRST_FULLY_ENFORCED_AFTER = 13_890_576L; + + @Test + public void theListNamesExactlyTheTwoMeasuredRanges() { + // Measured by reading vanityData and the certificate at every anchor height from the activation + // height 13,014,000 to the head at the time of measurement, 14,077,000, one height at a time. + // The list is a LIST so a third could be added without touching a rule; it has two because the + // chain has two. + assertThat(PqAnchorLapse.windows()).hasSize(2); + + final PqAnchorLapse.Window disarmed = PqAnchorLapse.windows().get(0); + assertThat(disarmed.firstBlock()).isEqualTo(13_267_824L); + assertThat(disarmed.lastBlock()).isEqualTo(13_268_944L); + assertThat(disarmed.anchorHeights()).isEqualTo(36L); + assertThat(disarmed.relaxation()).isEqualTo(PqAnchorLapse.Relaxation.EVERYTHING); + assertThat(disarmed.effectiveMinSeals()).isZero(); + assertThat(disarmed.reason()).contains("2026-08-10"); + + final PqAnchorLapse.Window lowered = PqAnchorLapse.windows().get(1); + assertThat(lowered.firstBlock()).isEqualTo(13_268_976L); + assertThat(lowered.lastBlock()).isEqualTo(13_890_544L); + assertThat(lowered.anchorHeights()).isEqualTo(19_425L); + assertThat(lowered.relaxation()).isEqualTo(PqAnchorLapse.Relaxation.THRESHOLD_ONLY); + assertThat(lowered.effectiveMinSeals()).isEqualTo(1); + } + + @Test + public void theTwoRangesAreAdjacentButDisjoint() { + // The second begins at the next anchor height after the first ends. They must not overlap, and + // there must be no unnamed anchor height between them, because a height in neither range is + // judged strictly and there is no such height here. + assertThat(DISARMED_LAST + PqAnchorLapse.ANCHOR_SPACING).isEqualTo(LOWERED_FIRST); + assertThat(PqAnchorLapse.windows().get(0).lastBlock()) + .isLessThan(PqAnchorLapse.windows().get(1).firstBlock()); + } + + @Test + public void theStatedSpansAgreeWithTheBounds() { + // 36, not 37: (13,268,944 - 13,267,824) / 32 + 1 = 36. And 19,425 for the second range. The + // span is carried in each entry so the two cannot drift apart in silence; this asserts the + // arithmetic from the outside as well. + for (final PqAnchorLapse.Window window : PqAnchorLapse.windows()) { + assertThat((window.lastBlock() - window.firstBlock()) % PqAnchorLapse.ANCHOR_SPACING).isZero(); + assertThat((window.lastBlock() - window.firstBlock()) / PqAnchorLapse.ANCHOR_SPACING + 1L) + .isEqualTo(window.anchorHeights()); + } + } + + // ----------------------------------------------------------------------------------------------- + // isDisarmed: TRUE only in the first range, and deliberately FALSE in the second. + // ----------------------------------------------------------------------------------------------- + + @Test + public void isDisarmedIsTrueOnlyWhereNothingWasInForce() { + assertThat(PqAnchorLapse.isDisarmed(DISARMED_FIRST)).isTrue(); + assertThat(PqAnchorLapse.isDisarmed(DISARMED_LAST)).isTrue(); + + // The neighbouring anchor heights, both measured to carry a digest, stay under the strict rules. + assertThat(PqAnchorLapse.isDisarmed(LAST_ENFORCED_BEFORE)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(DISARMED_FIRST - 1L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(DISARMED_LAST + 1L)).isFalse(); + + // And FALSE across the whole second range: there the digest and the ordering did hold, and a + // relaxation that leaked into it would throw away the binding on 19,425 anchor heights. + assertThat(PqAnchorLapse.isDisarmed(LOWERED_FIRST)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(13_500_016L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(LOWERED_LAST)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(FIRST_FULLY_ENFORCED_AFTER)).isFalse(); + } + + @Test + public void everyAnchorHeightOfTheDisarmedRangeIsCovered() { + long covered = 0L; + for (long height = DISARMED_FIRST; + height <= DISARMED_LAST; + height += PqAnchorLapse.ANCHOR_SPACING) { + assertThat(PqAnchorLapse.isDisarmed(height)) + .describedAs("anchor height %d of the disarmed range", height) + .isTrue(); + covered++; + } + assertThat(covered).isEqualTo(36L); + + // The rest of the chain is untouched. + assertThat(PqAnchorLapse.isDisarmed(0L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(13_014_000L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(13_000_000L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(14_077_000L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(Long.MAX_VALUE)).isFalse(); + } + + // ----------------------------------------------------------------------------------------------- + // historicMinSeals: the threshold that was really in force, and only where it was lower. + // ----------------------------------------------------------------------------------------------- + + @Test + public void historicMinSealsAnswersOnlyInsideTheLoweredRange() { + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST)).hasValue(1); + assertThat(PqAnchorLapse.historicMinSeals(13_500_016L)).hasValue(1); + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_LAST)).hasValue(1); + + // One anchor step past the end and the configured schedule applies again, unchanged. This is + // the assertion that stops the lowered threshold from becoming permanent. + assertThat(PqAnchorLapse.historicMinSeals(FIRST_FULLY_ENFORCED_AFTER)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_LAST + 1L)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST - 1L)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(LAST_ENFORCED_BEFORE)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(14_077_000L)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(Long.MAX_VALUE)).isEmpty(); + + // And it does NOT answer inside the disarmed range: there the rules are skipped whole, so a + // threshold would be a second, contradictory way of saying the same thing. + assertThat(PqAnchorLapse.historicMinSeals(DISARMED_FIRST)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(DISARMED_LAST)).isEmpty(); + } + + @Test + public void theFloorInsideTheLoweredRangeIsOneAndNotZero() { + // Measured: 2,926 of the 19,425 anchor heights in that range carry fewer seals than the + // schedule asks, the counts seen are 1 and 2, and NOT ONE carries zero. A floor of zero would + // therefore accept an empty certificate at a height where a certificate was in fact required, + // which is weaker than the history needs. + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST)).hasValue(1); + assertThat(PqAnchorLapse.windows().get(1).effectiveMinSeals()).isPositive(); + } + + @Test + public void windowCoveringNamesTheRangeOrNothing() { + assertThat(PqAnchorLapse.windowCovering(DISARMED_FIRST)).isPresent(); + assertThat(PqAnchorLapse.windowCovering(DISARMED_FIRST).get().lastBlock()) + .isEqualTo(DISARMED_LAST); + assertThat(PqAnchorLapse.windowCovering(LOWERED_FIRST)).isPresent(); + assertThat(PqAnchorLapse.windowCovering(LOWERED_FIRST).get().relaxation()) + .isEqualTo(PqAnchorLapse.Relaxation.THRESHOLD_ONLY); + assertThat(PqAnchorLapse.windowCovering(FIRST_FULLY_ENFORCED_AFTER)).isEmpty(); + assertThat(PqAnchorLapse.earliestAffectedBlock()).contains(DISARMED_FIRST); + } + + @Test + public void theDisarmedRangeIsTheSameOBJECTTheOrderingExceptionUses() { + // A second copy of these bounds anywhere else is a value that diverges. PqAnchor used to carry + // them as its own literals; it now derives them, and this is the assertion that says so. + assertThat(PqAnchor.UNORDERED_WINDOW_FIRST).isEqualTo(DISARMED_FIRST); + assertThat(PqAnchor.UNORDERED_WINDOW_LAST).isEqualTo(DISARMED_LAST); + } + + // ----------------------------------------------------------------------------------------------- + // The self-checks can say no. A guard that has never refused anything cannot be believed. + // ----------------------------------------------------------------------------------------------- + + @Test + public void aRangeThatContradictsItselfIsRefusedRatherThanAccepted() { + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + 100L, 99L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "backwards")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty range"); + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + -1L, 100L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "negative")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + 100L, 200L, 0L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "no heights")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one anchor height"); + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + 100L, 200L, 4L, PqAnchorLapse.Relaxation.THRESHOLD_ONLY, -1, "negative floor")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + public void aRangeCoversItsOwnBoundsAndNothingOutsideThem() { + final PqAnchorLapse.Window window = + new PqAnchorLapse.Window( + 1_000L, 1_032L, 2L, PqAnchorLapse.Relaxation.THRESHOLD_ONLY, 1, "probe"); + assertThat(window.covers(1_000L)).isTrue(); + assertThat(window.covers(1_016L)).isTrue(); + assertThat(window.covers(1_032L)).isTrue(); + assertThat(window.covers(999L)).isFalse(); + assertThat(window.covers(1_033L)).isFalse(); + assertThat(window.toString()).contains("1000").contains("1032").contains("THRESHOLD_ONLY"); + } + + @Test + public void theListIsUnmodifiable() { + // A range a node could widen at runtime would let a future lapse pass unnoticed. + final List windows = PqAnchorLapse.windows(); + assertThatThrownBy( + () -> + windows.add( + new PqAnchorLapse.Window( + 1L, 2L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "injected"))) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void theRangesBelongToThisChainAndAreDeclaredSo() { + assertThat(PqAnchorLapse.CHAIN_ID).isEqualTo(2800L); + assertThat(PqAnchorLapse.ANCHOR_SPACING).isEqualTo(32L); + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java index c79a884..ce64645 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java @@ -19,6 +19,7 @@ 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.common.bft.PqAnchorLapse; import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; import org.hyperledger.besu.ethereum.core.BlockHeader; import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; @@ -128,6 +129,16 @@ public class PqAnchorDigestRule implements DetachedBlockHeaderValidationRule { return true; } + // SECOND GATE: the named historical windows in which these rules were not in force. Inside one + // of them vanityData carries the client's version string rather than a digest, so there is no + // digest to match and no recomputation can produce one; refusing would stop this node forever + // at a height the whole network holds. See PqAnchorLapse for what happened, how the bounds were + // measured, and why an exception written anywhere other than validation moves the error instead + // of ending it. + if (PqAnchorLapse.isDisarmed(number)) { + return acceptAsWrittenInsideLapse(header, number); + } + try { final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); @@ -193,6 +204,53 @@ public class PqAnchorDigestRule implements DetachedBlockHeaderValidationRule { } } + /** + * Accept a header from a named unenforced window as it was written, after the one check that is + * not relaxed at any height. + * + *

Distinctness is kept and the difference is the whole reason this is not a bare {@code return + * true}: sortedness fixes ONE accepted order for a given set, while distinctness is what makes + * "repeat one seal to inflate the count" unrepresentable in the grammar of the format. Every + * header of the measured window has distinct, non-negative indices, so this admits exactly the + * history that exists and nothing weaker. + * + *

Decoding must still succeed. A header this rule cannot parse has not been shown to be one of + * the headers the window is about, so it is refused here as everywhere else in this file. + * + * @param header the header being judged + * @param number its height + * @return true iff the carried certificate decodes and its indices are distinct and non-negative + */ + private boolean acceptAsWrittenInsideLapse(final BlockHeader header, final long number) { + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final List certificate = List.copyOf(extraData.getFalconSeals()); + if (!PqAnchor.hasDistinctNonNegativeIndices(certificate)) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - it falls inside the historical range {} in " + + "which the anchor rules were not in force, but its certificate repeats a " + + "validator index or carries a negative one. That is not a shape any header of " + + "that range has, and it is the one property the exception does not relax.", + number, + PqAnchorLapse.windowCovering(number).orElse(null)); + return false; + } + LOG.debug( + "AERE PQ ANCHOR (R1): block {} accepted as written; it falls inside the historical range " + + "{} in which the anchor rules were not in force, so it carries no digest to bind.", + number, + PqAnchorLapse.windowCovering(number).orElse(null)); + return true; + } catch (final Exception e) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - it falls inside a historical unenforced range " + + "but its extraData could not be decoded: {}", + number, + e.toString()); + return false; + } + } + @Override public boolean includeInLightValidation() { return true; diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java index a435074..9be0c7b 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java @@ -20,6 +20,7 @@ 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.PqAnchorLapse; import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; import org.hyperledger.besu.datatypes.Address; @@ -30,6 +31,7 @@ import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule; import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.OptionalInt; import java.util.Set; import org.apache.tuweni.bytes.Bytes32; @@ -125,6 +127,15 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { return true; } + // SECOND GATE, and the SAME method R1 asks, for the same reason both rules share + // anchorAppliesAt: if the two rules each decided this boundary their own way, a header accepted + // by one and refused by the other would be a chain break. Inside a named window the threshold + // was not in force when the header was written and the indices were written in arrival order, + // so neither can be required of it now. + if (PqAnchorLapse.isDisarmed(number)) { + return acceptAsWrittenInsideLapse(header, number); + } + try { if (parent == null) { LOG.warn( @@ -162,7 +173,26 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); final List certificate = List.copyOf(extraData.getFalconSeals()); final int k = certificate.size(); - final int required = config.minSealsAt(number); + + // THE THRESHOLD THIS HEIGHT WAS ACTUALLY JUDGED BY. Normally the configured schedule. Inside + // a named historical range it is the LOWER of the schedule and the emergency ceiling that was + // in force when these headers were written, which is the same lever the fleet itself used at + // the time, replayed from a fixed range instead of from a runtime option. Asking a header for + // more than was asked of the proposer that wrote it refuses history that no node can + // reproduce; asking for the ceiling rather than for nothing keeps the strongest claim the + // range supports, and the ceiling is a measured floor, never zero. See PqAnchorLapse. + int required = config.minSealsAt(number); + final OptionalInt inForce = PqAnchorLapse.historicMinSeals(number); + if (inForce.isPresent() && inForce.getAsInt() < required) { + LOG.debug( + "AERE PQ ANCHOR (R2): block {} sits in the historical range {}, so the threshold " + + "applied is the {} that was in force then, not the {} the schedule asks for.", + number, + PqAnchorLapse.windowCovering(number).orElse(null), + inForce.getAsInt(), + required); + required = inForce.getAsInt(); + } if (k < required) { LOG.warn( @@ -285,6 +315,50 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { } } + /** + * Accept a header from a named unenforced window as it was written, after the one check that is + * not relaxed at any height. + * + *

The threshold is not applied here and neither is sortedness, because neither was applied + * when these headers were written; applying them now would refuse history the whole network + * holds. Distinctness IS applied, for the reason set out in {@link PqAnchor#hasAcceptableIndices} + * and in {@link PqAnchorLapse}: it is the part that is load bearing against an attacker rather + * than against ambiguity, and it is measured true on every header of the window. + * + * @param header the header being judged + * @param number its height + * @return true iff the carried certificate decodes and its indices are distinct and non-negative + */ + private boolean acceptAsWrittenInsideLapse(final BlockHeader header, final long number) { + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final List certificate = List.copyOf(extraData.getFalconSeals()); + if (!PqAnchor.hasDistinctNonNegativeIndices(certificate)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - it falls inside the historical range {} in " + + "which the anchor rules were not in force, but its certificate repeats a " + + "validator index or carries a negative one, which no header of that range does.", + number, + PqAnchorLapse.windowCovering(number).orElse(null)); + return false; + } + LOG.debug( + "AERE PQ ANCHOR (R2): block {} accepted as written with {} seal(s); it falls inside the " + + "historical range {} in which neither the threshold nor the ordering was in force.", + number, + certificate.size(), + PqAnchorLapse.windowCovering(number).orElse(null)); + return true; + } catch (final Exception e) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - it falls inside a historical unenforced range " + + "but its extraData could not be decoded: {}", + 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 diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java new file mode 100644 index 0000000..9451ec7 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java @@ -0,0 +1,548 @@ +/* + * 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.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.PqAnchorLapse; +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 java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +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.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * The two historical ranges in which the anchor rules were not fully in force, as the two rules see + * them. + * + *

The configuration here is the one the live chain runs, not the small synthetic one the other + * rule tests use, because the whole subject is a set of REAL heights: the activation height, the + * 32-block anchor spacing and the threshold schedule all have to be the production ones, or the + * heights under test are not anchor heights at all and every assertion below would pass on a rule + * that does nothing. {@link #everyHeightUnderTestIsAnAnchorHeightOfTheLiveConfiguration()} is the + * positive control for exactly that. + * + *

Every acceptance in this file is paired with the same header shape at a height where it must + * still be refused, and every refusal is paired with an honest header at that same height, which + * must still be accepted. Without the first pairing the tests would pass on a rule that accepts + * everything; without the second they would pass on a rule that refuses everything. + */ +public class PqAnchorDisarmedWindowTest { + + /** The live activation height H. */ + private static final long H_LIVE = 13_014_000L; + + /** The live anchor spacing. */ + private static final int INTERVAL = 32; + + /** The last anchor height fully under the rules before the interruption. */ + private static final long BEFORE = 13_267_792L; + + /** First height of the range in which nothing was in force. */ + private static final long OFF_FIRST = 13_267_824L; + + /** A height in the middle of that range. */ + private static final long OFF_MIDDLE = 13_268_016L; + + /** Last height of that range. */ + private static final long OFF_LAST = 13_268_944L; + + /** First height of the range in which only the threshold was lowered. */ + private static final long LOW_FIRST = 13_268_976L; + + /** A height in the middle of that range. */ + private static final long LOW_MIDDLE = 13_500_016L; + + /** Last height of that range. */ + private static final long LOW_LAST = 13_890_544L; + + /** The first anchor height fully under the rules again. */ + private static final long AFTER = 13_890_576L; + + /** + * The vanityData the headers of the first range actually carry: the client's version string, + * which is what a node writes when the anchor is switched off. Read off a live header of the + * range. + */ + private static final Bytes CLIENT_VANITY = + Bytes.fromHexString("0x00000000000000626573752032362e372d646576656c6f702d64323033323031"); + + /** Signer indices exactly as one header of the first range carries them: distinct, not sorted. */ + private static final List ARRIVAL_ORDER = List.of(2, 6, 1); + + /** A second measured arrival order, from another header of the same range. */ + private static final List ARRIVAL_ORDER_2 = List.of(1, 3, 6, 0); + + private final PqAnchorConfig live = + new PqAnchorConfig( + CHAIN_ID, + H_LIVE, + Map.of(H_LIVE, 0, 13_034_000L, 3), + OptionalInt.empty(), + false, + OptionalInt.empty(), + OptionalInt.of(INTERVAL)); + + private final LocalRegistry registry = new LocalRegistry(); + private final PqAnchorDigestRule r1 = new PqAnchorDigestRule(live); + private final PqAnchorSealsRule r2 = new PqAnchorSealsRule(live, registry); + + // ----------------------------------------------------------------------------------------------- + // The heights under test really are anchor heights. Without this the file measures nothing. + // ----------------------------------------------------------------------------------------------- + + @Test + public void everyHeightUnderTestIsAnAnchorHeightOfTheLiveConfiguration() { + for (final long height : + List.of(BEFORE, OFF_FIRST, OFF_MIDDLE, OFF_LAST, LOW_FIRST, LOW_MIDDLE, LOW_LAST, AFTER)) { + assertThat(live.anchorAppliesAt(height)) + .describedAs("height %d must be one the rules judge, or this file proves nothing", height) + .isTrue(); + assertThat(live.minSealsAt(height)) + .describedAs("the schedule asks for three seals at height %d", height) + .isEqualTo(3); + } + assertThat(PqAnchorLapse.isDisarmed(OFF_FIRST)).isTrue(); + assertThat(PqAnchorLapse.isDisarmed(OFF_LAST)).isTrue(); + assertThat(PqAnchorLapse.isDisarmed(BEFORE)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(LOW_FIRST)).isFalse(); + assertThat(PqAnchorLapse.historicMinSeals(LOW_FIRST)).hasValue(1); + assertThat(PqAnchorLapse.historicMinSeals(AFTER)).isEmpty(); + } + + // ----------------------------------------------------------------------------------------------- + // RANGE 1, nothing in force: a header of the shape the chain holds is accepted by both rules. + // ----------------------------------------------------------------------------------------------- + + @Test + public void r1AcceptsAHeaderOfTheFirstRangeAsWritten() { + for (final long height : List.of(OFF_FIRST, OFF_MIDDLE, OFF_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r1.validate(block, parent)) + .describedAs( + "height %d carries the client version string in vanityData and a certificate in " + + "arrival order; that is what the chain holds, and a node syncing from genesis " + + "must be able to pass it", + height) + .isTrue(); + } + } + + @Test + public void r2AcceptsAHeaderOfTheFirstRangeAsWritten() { + for (final long height : List.of(OFF_FIRST, OFF_MIDDLE, OFF_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("height %d must pass the seals rule too, or the node stops 32 blocks on", height) + .isTrue(); + } + } + + @Test + public void bothRulesAcceptTheSecondMeasuredArrivalOrder() { + final BlockHeader parent = parentHeader(OFF_MIDDLE - 1L); + final BlockHeader block = unenforcedHeader(OFF_MIDDLE, parent, ARRIVAL_ORDER_2); + assertThat(r1.validate(block, parent)).isTrue(); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void insideTheFirstRangeTheThresholdIsNotAppliedEither() { + // The threshold is one of the things that was switched off, so a header of this range may carry + // fewer seals than K. The schedule asks three; this certificate carries one. + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader block = unenforcedHeader(OFF_FIRST, parent, List.of(4)); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void insideTheFirstRangeAnHonestHeaderIsStillAccepted() { + // The exception must not break the ordinary case: a correctly written header that happens to + // fall inside the range is accepted too, so nothing has to be re-produced. + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader block = honestBlock(OFF_FIRST, parent, List.of(0, 1, 2)); + assertThat(r1.validate(block, parent)).isTrue(); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // RANGE 2, only the threshold lowered: the digest and the ordering are STILL required, and the + // short certificate is accepted down to the floor that was really in force, and no further. + // ----------------------------------------------------------------------------------------------- + + @Test + public void insideTheSecondRangeTheDigestIsStillRequired() { + // This is the assertion that stops the first range's relaxation from leaking into the second. + // If it ever passes, the binding has been thrown away on 19,425 anchor heights. + for (final long height : List.of(LOW_FIRST, LOW_MIDDLE, LOW_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader noDigest = unenforcedHeader(height, parent, List.of(0, 1, 2)); + assertThat(r1.validate(noDigest, parent)) + .describedAs("height %d is inside the lowered-threshold range, where the digest DID hold", height) + .isFalse(); + + // Positive control at the same height. + assertThat(r1.validate(honestBlock(height, parent, List.of(0, 1, 2)), parent)).isTrue(); + } + } + + @Test + public void insideTheSecondRangeTheOrderingIsStillRequired() { + final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + final BlockHeader unsorted = digestBoundButUnsorted(LOW_MIDDLE, parent, ARRIVAL_ORDER); + assertThat(r2.validate(unsorted, parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(1, 2, 6)), parent, context)) + .isTrue(); + } + + @Test + public void insideTheSecondRangeAShortCertificateIsAccepted() { + // Measured: 2,926 of the 19,425 anchor heights of this range carry one or two seals where the + // schedule asks three, because the fleet was running with the emergency ceiling lowered. + for (final long height : List.of(LOW_FIRST, LOW_MIDDLE, LOW_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(live.minSealsAt(height)).isEqualTo(3); + + assertThat(r2.validate(honestBlock(height, parent, List.of(0, 1)), parent, context)) + .describedAs("two seals at height %d, the count actually written there", height) + .isTrue(); + assertThat(r2.validate(honestBlock(height, parent, List.of(5)), parent, context)) + .describedAs("one seal at height %d, the lowest count measured anywhere in the range", height) + .isTrue(); + } + } + + @Test + public void insideTheSecondRangeAnEmptyCertificateIsStillRefused() { + // The floor is ONE, not zero, because one is the ceiling that was actually in force and no + // header of the range carries zero. A floor of zero would accept an empty certificate where a + // certificate was in fact required, which is weaker than the history needs. + final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of()), parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(0)), parent, context)).isTrue(); + } + + @Test + public void insideTheSecondRangeAnInvalidSignatureIsStillRefused() { + // Only the COUNT moves. Every seal still has to verify, and still has to belong to a validator + // of the parent. + final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + final List bad = List.of(new FalconSeal(0, Bytes.repeat((byte) 0x5a, 655))); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), bad); + final BlockHeader block = header(LOW_MIDDLE, parent.getHash(), extraData(digest, bad)); + assertThat(r2.validate(block, parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(0)), parent, context)).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // OUTSIDE both ranges: nothing changed at all. + // ----------------------------------------------------------------------------------------------- + + @Test + public void outsideBothRangesAHeaderWithNoDigestIsStillRefusedByR1() { + for (final long height : List.of(BEFORE, LOW_FIRST, AFTER)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r1.validate(block, parent)) + .describedAs( + "height %d is outside the range where nothing was in force, so a header with no " + + "digest must still stop this node; that is what a NEW lapse looks like", + height) + .isFalse(); + + final BlockHeader honest = honestBlock(height, parent, List.of(0, 1, 2)); + assertThat(r1.validate(honest, parent)) + .describedAs("an honest header at height %d is still accepted", height) + .isTrue(); + } + } + + @Test + public void outsideBothRangesTheSameShapeIsStillRefusedByR2() { + for (final long height : List.of(BEFORE, LOW_FIRST, AFTER)) { + final BlockHeader parent = parentHeader(height - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r2.validate(block, parent, context)) + .describedAs("height %d is outside the disarmed range, so unsorted indices are refused", height) + .isFalse(); + + final BlockHeader honest = honestBlock(height, parent, List.of(0, 1, 2)); + assertThat(r2.validate(honest, parent, context)) + .describedAs("an honest header at height %d is still accepted", height) + .isTrue(); + } + } + + @Test + public void afterBothRangesTheFullThresholdIsBack() { + // One anchor step past the end of the lowered range the schedule applies again, unchanged. + // This is the assertion that stops the lowered threshold from becoming permanent. + final BlockHeader parent = parentHeader(AFTER - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(live.minSealsAt(AFTER)).isEqualTo(3); + assertThat(r2.validate(honestBlock(AFTER, parent, List.of(0, 1)), parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(AFTER, parent, List.of(0, 1, 2)), parent, context)).isTrue(); + } + + @Test + public void wellBeyondTheRangesNothingIsRelaxed() { + final long far = 14_077_008L; + assertThat(live.anchorAppliesAt(far)).isTrue(); + final BlockHeader parent = parentHeader(far - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(r1.validate(unenforcedHeader(far, parent, ARRIVAL_ORDER), parent)).isFalse(); + assertThat(r2.validate(honestBlock(far, parent, List.of(0, 1)), parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(far, parent, List.of(0, 1, 2)), parent, context)).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // The bounds are exact, to one anchor step and to one block. + // ----------------------------------------------------------------------------------------------- + + @Test + public void theBoundsAreExactToOneAnchorStep() { + assertThat(BEFORE + INTERVAL).isEqualTo(OFF_FIRST); + assertThat(OFF_LAST + INTERVAL).isEqualTo(LOW_FIRST); + assertThat(LOW_LAST + INTERVAL).isEqualTo(AFTER); + + final BlockHeader beforeParent = parentHeader(BEFORE - 1L); + assertThat(r1.validate(unenforcedHeader(BEFORE, beforeParent, ARRIVAL_ORDER), beforeParent)) + .isFalse(); + + final BlockHeader firstParent = parentHeader(OFF_FIRST - 1L); + assertThat(r1.validate(unenforcedHeader(OFF_FIRST, firstParent, ARRIVAL_ORDER), firstParent)) + .isTrue(); + + final BlockHeader lastParent = parentHeader(OFF_LAST - 1L); + assertThat(r1.validate(unenforcedHeader(OFF_LAST, lastParent, ARRIVAL_ORDER), lastParent)) + .isTrue(); + + final BlockHeader lowParent = parentHeader(LOW_FIRST - 1L); + assertThat(r1.validate(unenforcedHeader(LOW_FIRST, lowParent, ARRIVAL_ORDER), lowParent)) + .isFalse(); + + // And the far end of the lowered range, to one anchor step, on the threshold. + final BlockHeader lowLastParent = parentHeader(LOW_LAST - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(r2.validate(honestBlock(LOW_LAST, lowLastParent, List.of(0)), lowLastParent, context)) + .isTrue(); + final BlockHeader afterParent = parentHeader(AFTER - 1L); + assertThat(r2.validate(honestBlock(AFTER, afterParent, List.of(0)), afterParent, context)) + .isFalse(); + } + + @Test + public void theBoundsAreExactToOneBlockOnBothRules() { + // The heights either side of the bounds are not anchor heights, so both rules stand down there + // for the ordinary reason. Asserted so that "accepted" at OFF_FIRST - 1 is not mistaken for the + // exception having leaked one block down. + assertThat(live.anchorAppliesAt(OFF_FIRST - 1L)).isFalse(); + assertThat(live.anchorAppliesAt(OFF_LAST + 1L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(OFF_FIRST - 1L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(OFF_LAST + 1L)).isFalse(); + assertThat(PqAnchorLapse.historicMinSeals(LOW_LAST + 1L)).isEmpty(); + } + + // ----------------------------------------------------------------------------------------------- + // What the exception deliberately does NOT relax, at any height. + // ----------------------------------------------------------------------------------------------- + + @Test + public void insideTheFirstRangeARepeatedIndexIsStillRefusedByBothRules() { + // Sortedness fixes one accepted order for a given set; distinctness is what makes "repeat one + // seal to inflate the count" unrepresentable. Measured on every header of the range: distinct + // in all of them. So this refusal costs nothing and keeps the attack unrepresentable. + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader repeated = unenforcedHeader(OFF_FIRST, parent, List.of(2, 6, 2)); + assertThat(r1.validate(repeated, parent)).isFalse(); + assertThat(r2.validate(repeated, parent, contextWith(VALIDATORS))).isFalse(); + + // Positive control: the same rule at the same height accepts the distinct version. + final BlockHeader distinct = unenforcedHeader(OFF_FIRST, parent, ARRIVAL_ORDER); + assertThat(r1.validate(distinct, parent)).isTrue(); + assertThat(r2.validate(distinct, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void insideTheFirstRangeAnUndecodableHeaderIsStillRefused() { + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader garbage = + header(OFF_FIRST, parent.getHash(), Bytes.fromHexString("0xdeadbeef")); + assertThat(r1.validate(garbage, parent)).isFalse(); + assertThat(r2.validate(garbage, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void belowTheActivationHeightNothingIsJudgedAtAll() { + // The ranges sit far above H, so this only restates the height gate; it is here because a + // future edit that moved the range gates ABOVE the height gate would make the rules decode + // headers on the whole existing chain, which is the property that lets this binary be warmed. + final BlockHeader parent = parentHeader(H_LIVE - 2L); + final BlockHeader block = unenforcedHeader(H_LIVE - 1L, parent, ARRIVAL_ORDER); + assertThat(r1.validate(block, parent)).isTrue(); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // Helpers. + // ----------------------------------------------------------------------------------------------- + + /** + * A header of the shape the first range holds: the client version string in vanityData instead of + * a digest, and the certificate written in the order the commits arrived. + */ + private BlockHeader unenforcedHeader( + final long number, final BlockHeader parent, final List indicesInArrivalOrder) { + return header( + number, parent.getHash(), extraData(CLIENT_VANITY, sealsFor(parent, indicesInArrivalOrder))); + } + + /** A header whose digest is correctly bound but whose indices are left in arrival order. */ + private BlockHeader digestBoundButUnsorted( + final long number, final BlockHeader parent, final List indicesInArrivalOrder) { + final List seals = sealsFor(parent, indicesInArrivalOrder); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), seals); + return header(number, parent.getHash(), extraData(digest, seals)); + } + + /** A header an honest proposer under the armed rules writes: digest bound, indices sorted. */ + private BlockHeader honestBlock( + final long number, final BlockHeader parent, final List indices) { + final List sorted = PqAnchor.sortedByIndex(sealsFor(parent, indices)); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), sorted); + return header(number, parent.getHash(), extraData(digest, sorted)); + } + + private List sealsFor(final BlockHeader parent, final List indices) { + // One return, one mutability. An early return of an immutable empty list here is what + // errorprone's MixedMutabilityReturnType refuses, and it is refusing something real: the empty + // case is not special, it is the case where the loop runs zero times. + final Bytes32 message = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + for (final int index : indices) { + seals.add(new FalconSeal(index, LocalRegistry.sign(index, message))); + } + return seals; + } + + private ProtocolContext contextWith(final Collection

parentValidators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(parentValidators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + /** + * A registry of the same shape as the Falcon one, with a signature that is a deterministic + * function of the index AND the message, so a seal replayed at another height fails exactly as a + * real verification would. It exists so that the positive controls are real acceptances rather + * than acceptances bought by a registry that agrees to everything. + */ + private static final class LocalRegistry implements PqSignerRegistry { + + private final Map binding = new HashMap<>(); + + private LocalRegistry() { + for (int i = 0; i < VALIDATORS.size(); i++) { + binding.put(i, VALIDATORS.get(i)); + } + } + + static Bytes sign(final int index, 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) & 0xFF); + } + return Bytes.wrap(out); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return binding.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, message)); + } + + @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 String toString() { + return "LocalRegistry"; + } + } +} diff --git a/patches/0003-aere-pq-anchor.patch b/patches/0003-aere-pq-anchor.patch index 9db6516..1611fb9 100644 --- a/patches/0003-aere-pq-anchor.patch +++ b/patches/0003-aere-pq-anchor.patch @@ -1,6 +1,6 @@ -From ce832eb0614c6029d1aaa503b3717a83b6d02734 Mon Sep 17 00:00:00 2001 +From 817da3e4ac2288e192129bbb69e8cfaaf1723812 Mon Sep 17 00:00:00 2001 From: Aere Network -Date: Sat, 15 Aug 2026 23:43:50 +0300 +Date: Sun, 16 Aug 2026 01:24:17 +0300 Subject: [PATCH] Aere Network: post-quantum certificate anchor for QBFT Puts a 32-byte digest of the validator certificate into vanityData, which is @@ -27,6 +27,39 @@ above, using the verified registry; two or more blocks below the schedule nothing changes. Covered by the new PqParentHeightAlignmentTest, and measured on a node synced from genesis, which now crosses the first anchor height instead of stopping one block below it. + +Updated 2026-08-16: names the two historical ranges on chain 2800 in which +these rules were not fully in force, so that a node validating from genesis can +cross them. On 2026-08-10 the whole validator set was restarted inside a few +minutes; a node that has just restarted holds no seals until it takes part in +one commit, so for a moment every node held none, the next height was an anchor +height, nobody could propose and the chain stopped. The measure that restarted +it switched these rules off across the set, and then restored them with the +emergency seal ceiling lowered rather than at the scheduled threshold. The +headers written meanwhile are canonical and cannot be reproduced: over the first +range they carry the client version string in vanityData instead of the anchor +digest and their certificates are in arrival order rather than sorted; over the +second, much longer range they carry a correct digest and sorted indices but +fewer seals than the schedule asks for. Nodes already holding the chain never +revalidate them, which is why this stayed invisible; a node validating from +genesis stopped at the first header of each range and could not pass it. + +The exception therefore sits at VALIDATION, where a header is accepted as it was +written, and not in any recomputation, which would only move the error. The +ranges are fixed constants that a node cannot widen at runtime, so a NEW lapse +still stops the client, which is what should happen; outside them nothing +changes at all. Neither property is monotone, so the bounds were not searched +but measured: every anchor height from the activation height to the head was +read one at a time and its vanityData and certificate decoded, and exactly two +contiguous ranges appear. What the exception does not relax: over the first +range a certificate must still decode and must still carry non-negative, +pairwise distinct indices, so a repeated seal stays unrepresentable at every +height; over the second range only the count moves, every seal is still verified +against the parent's validator set, and the count keeps the floor that was +really in force rather than dropping to zero. Covered by the new PqAnchorLapse, +PqAnchorLapseTest and PqAnchorDisarmedWindowTest, and measured on a node synced +from genesis, which now crosses both ranges instead of stopping at the first +header of each. --- .../org/hyperledger/besu/cli/BesuCommand.java | 24 + .../cli/options/AerePqEmergencyOptions.java | 245 ++ @@ -38,8 +71,9 @@ instead of stopping one block below it. .../consensus/common/bft/BftExtraData.java | 50 +- .../besu/consensus/common/bft/FalconSeal.java | 88 + .../common/bft/FalconSealSupport.java | 3749 +++++++++++++++++ - .../besu/consensus/common/bft/PqAnchor.java | 335 ++ + .../besu/consensus/common/bft/PqAnchor.java | 339 ++ .../consensus/common/bft/PqAnchorConfig.java | 1320 ++++++ + .../consensus/common/bft/PqAnchorLapse.java | 375 ++ .../common/bft/PqAnchorNotReadyException.java | 132 + .../common/bft/PqAnchorSyncModeGuard.java | 130 + .../common/bft/PqAnchorThresholdGuard.java | 252 ++ @@ -56,6 +90,7 @@ instead of stopping one block below it. .../common/bft/PqAnchorConfigTest.java | 691 +++ .../bft/PqAnchorEmergencyConfigTest.java | 120 + .../common/bft/PqAnchorIntervalTest.java | 186 + + .../common/bft/PqAnchorLapseTest.java | 249 ++ .../common/bft/PqAnchorMinSealsFloorTest.java | 153 + .../common/bft/PqAnchorProducerCostTest.java | 251 ++ .../common/bft/PqAnchorSealCapTest.java | 150 + @@ -90,27 +125,29 @@ instead of stopping one block below it. .../QbftBlockCreatorFactory.java | 35 +- .../FalconSealValidationRule.java | 501 +++ .../PqAnchorDigestAttachedRule.java | 115 + - .../PqAnchorDigestRule.java | 207 + - .../PqAnchorSealsRule.java | 329 ++ + .../PqAnchorDigestRule.java | 265 ++ + .../PqAnchorSealsRule.java | 403 ++ .../PqEmergencyShoutRule.java | 134 + .../PqRegistryBindingRule.java | 91 + .../qbft/QbftAnchorRuleWiringTest.java | 194 + .../FalconSealLogThrottleTest.java | 182 + ...alconSealValidationRuleRetirementTest.java | 85 + .../PqAnchorDigestRuleTest.java | 359 ++ + .../PqAnchorDisarmedWindowTest.java | 548 +++ .../PqAnchorSealsRuleTest.java | 690 +++ .../PqAnchorTestSupport.java | 144 + .../PqArmedWithoutRegistryTest.java | 185 + .../PqEmergencyShoutRuleTest.java | 78 + .../PqForkGateFeedTest.java | 189 + .../eth/sync/DownloadHeadersStep.java | 103 +- - 76 files changed, 24406 insertions(+), 45 deletions(-) + 79 files changed, 25714 insertions(+), 45 deletions(-) create mode 100755 app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java create mode 100755 app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java + create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java @@ -126,6 +163,7 @@ instead of stopping one block below it. create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java + create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java @@ -156,6 +194,7 @@ instead of stopping one block below it. create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java + create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java @@ -5004,10 +5043,10 @@ index 000000000..af694dcf9 +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java new file mode 100755 -index 000000000..a383bcf1c +index 000000000..59c2d8683 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java -@@ -0,0 +1,335 @@ +@@ -0,0 +1,339 @@ +/* + * Copyright contributors to Besu / Aere Network. + * @@ -5259,11 +5298,17 @@ index 000000000..a383bcf1c + * [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. ++ * ++ *

DERIVED, not written a second time. The bounds themselves live in {@link PqAnchorLapse}, ++ * which is the one place a historical window is named, because the SAME interruption that left ++ * these certificates unsorted also left their headers without an anchor digest, and a value that ++ * appears in two places diverges. This constant is kept because it is the name this class has ++ * always used for the first bound. + */ -+ public static final long UNORDERED_WINDOW_FIRST = 13_267_824L; ++ public static final long UNORDERED_WINDOW_FIRST = PqAnchorLapse.windows().get(0).firstBlock(); + + /** 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; ++ public static final long UNORDERED_WINDOW_LAST = PqAnchorLapse.windows().get(0).lastBlock(); + + /** + * Whether the certificate's indices are non-negative and pairwise DISTINCT, in any order. @@ -5325,9 +5370,7 @@ index 000000000..a383bcf1c + if (hasStrictlyIncreasingIndices(certificate)) { + return true; + } -+ final boolean inHistoricalWindow = -+ blockNumber >= UNORDERED_WINDOW_FIRST && blockNumber <= UNORDERED_WINDOW_LAST; -+ return inHistoricalWindow && hasDistinctNonNegativeIndices(certificate); ++ return PqAnchorLapse.isDisarmed(blockNumber) && hasDistinctNonNegativeIndices(certificate); + } + + /** @@ -6669,6 +6712,387 @@ index 000000000..ce9f081b5 + + '}'; + } +} +diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java +new file mode 100755 +index 000000000..a7c8c9bf1 +--- /dev/null ++++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java +@@ -0,0 +1,375 @@ ++/* ++ * 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.Comparator; ++import java.util.List; ++import java.util.Optional; ++import java.util.OptionalInt; ++ ++/** ++ * The historical ranges on chain 2800 in which the anchor rules were not fully in force. ++ * ++ *

WHAT HAPPENED. On 2026-08-10 all nine validators were restarted inside a few minutes. A ++ * node that has just restarted holds no Falcon seals until it takes part in one commit, so for a ++ * moment every node held none; the very next height was an anchor height, its threshold asked for ++ * three seals, nobody could propose, and the chain stopped for nine minutes. The measure that ++ * restarted it was to switch the anchor rules off on all nine nodes at once, and then to restore ++ * them with the emergency seal ceiling lowered rather than at the scheduled threshold. That leaves ++ * TWO ranges of canonical headers, not one, and they fail a strict validator for different reasons: ++ * ++ *

    ++ *
  1. The rules off entirely. vanityData carries the client's usual version string instead ++ * of the anchor digest, although the certificate is attached, so the digest binding does not ++ * hold; and the certificate's validator indices are in the order the commit messages ARRIVED ++ * rather than sorted, so they are distinct but not increasing. ++ *
  2. The rules on, the threshold lowered. vanityData carries a correct digest and the ++ * indices are sorted, so both of those bind exactly as they should. What differs is the COUNT: ++ * a proposer running under the lowered ceiling legitimately wrote fewer seals than the ++ * configured schedule asks for, so the header is short of the threshold and nothing else. ++ *
++ * ++ *

WHY THE EXCEPTION BELONGS AT VALIDATION AND NOWHERE ELSE. Nodes that already hold these ++ * blocks never revalidate them, which is why the damage was invisible. A node built from this source ++ * and synced from genesis does validate them, and under the plain rules it stops at the first header ++ * of each range and never passes it. The history is what it is: no recomputation can turn these ++ * headers into headers that satisfy a rule they were written without. What a correct client can do ++ * is name the ranges and judge those headers by the rule that was actually in force when they were ++ * written, which is what every node holding the chain does today. A patch anywhere else moves the ++ * error instead of ending it. ++ * ++ *

THE BOUNDS, MEASURED, NOT SEARCHED. Neither property is monotone: inside the second ++ * range only about one anchor header in seven is short of the threshold and the rest are not, so a ++ * binary search would return a height that looks exactly like an answer and is not one. Every anchor ++ * height from the activation height 13,014,000 to the head at the time of measurement, 14,077,000, ++ * was therefore read one by one and its vanityData and certificate decoded: 25,252 heights, ++ * zero transport errors, and exactly the two contiguous ranges named below. Outside them there is ++ * not one header without a digest, not one with unsorted indices, and not one below the threshold. ++ * ++ *

WHAT THE RANGES DO NOT RELAX, AT ANY HEIGHT. Over the first range a certificate must ++ * still decode and must still carry non-negative, pairwise DISTINCT indices: sortedness fixes one ++ * accepted order for a given set, while distinctness is the part that is load bearing against an ++ * attacker, because it is what makes "repeat one seal to inflate the count" unrepresentable in the ++ * grammar of the format. Over the second range NOTHING is relaxed except the count, and even the ++ * count keeps a floor: the lowest number of seals measured anywhere in that range is ++ * 1, which is the ceiling that was actually in force, so that is the floor applied, ++ * not zero. Both ranges therefore admit exactly the history that exists and nothing weaker. ++ * ++ *

THE BOUNDS ARE FIXED IN CODE ON PURPOSE. A range a node could widen at runtime would let ++ * a future lapse pass unnoticed. Outside these ranges nothing changes at all, so a NEW unenforced ++ * header still stops this client, which is what should happen. Producers never consult this class: ++ * new headers are always written with the digest, with sorted indices and at the scheduled ++ * threshold, so the ranges are closed by construction and cannot grow. ++ * ++ *

THE TYPE IS A LIST BECAUSE A THIRD RANGE MUST NOT REQUIRE TOUCHING A RULE. The two ++ * entries below are the two the chain has. Adding another is an edit to this file alone. ++ */ ++public final class PqAnchorLapse { ++ ++ /** The chain these ranges belong to. They describe no other chain. */ ++ public static final long CHAIN_ID = 2800L; ++ ++ /** The anchor spacing in force over both ranges, used by the self-check below. */ ++ public static final long ANCHOR_SPACING = 32L; ++ ++ /** What was not in force over a range. */ ++ public enum Relaxation { ++ /** Nothing was in force: no digest binding, no ordering, no threshold. */ ++ EVERYTHING, ++ /** The digest and the ordering were in force; only the seal threshold was lowered. */ ++ THRESHOLD_ONLY ++ } ++ ++ /** ++ * One contiguous range of heights over which the anchor rules were not fully in force. ++ * ++ *

Both bounds are INCLUSIVE and both are measured anchor heights: the first is the first header ++ * that shows the defect and the last is the last one that shows it. ++ */ ++ public static final class Window { ++ ++ private final long firstBlock; ++ private final long lastBlock; ++ private final long anchorHeights; ++ private final Relaxation relaxation; ++ private final int effectiveMinSeals; ++ private final String reason; ++ ++ /** ++ * Names a range. ++ * ++ * @param firstBlock the first affected height, inclusive ++ * @param lastBlock the last affected height, inclusive ++ * @param anchorHeights how many anchor heights the range spans; carried so that the count and ++ * the bounds are checked against each other rather than written twice ++ * @param relaxation what was not in force ++ * @param effectiveMinSeals the seal threshold that WAS in force over the range, which is the ++ * floor still applied inside it; zero when nothing was in force ++ * @param reason why the range exists, and what was measured ++ */ ++ public Window( ++ final long firstBlock, ++ final long lastBlock, ++ final long anchorHeights, ++ final Relaxation relaxation, ++ final int effectiveMinSeals, ++ final String reason) { ++ if (firstBlock < 0L || lastBlock < firstBlock) { ++ throw new IllegalArgumentException( ++ "AERE PQ ANCHOR LAPSE: a window must be a non-negative, non-empty range, got [" ++ + firstBlock ++ + ", " ++ + lastBlock ++ + "]"); ++ } ++ if (anchorHeights < 1L) { ++ throw new IllegalArgumentException( ++ "AERE PQ ANCHOR LAPSE: a window must span at least one anchor height, got " ++ + anchorHeights); ++ } ++ if (effectiveMinSeals < 0) { ++ throw new IllegalArgumentException( ++ "AERE PQ ANCHOR LAPSE: the threshold in force cannot be negative, got " ++ + effectiveMinSeals); ++ } ++ this.firstBlock = firstBlock; ++ this.lastBlock = lastBlock; ++ this.anchorHeights = anchorHeights; ++ this.relaxation = relaxation; ++ this.effectiveMinSeals = effectiveMinSeals; ++ this.reason = reason; ++ } ++ ++ /** ++ * The first affected height. ++ * ++ * @return the inclusive lower bound ++ */ ++ public long firstBlock() { ++ return firstBlock; ++ } ++ ++ /** ++ * The last affected height. ++ * ++ * @return the inclusive upper bound ++ */ ++ public long lastBlock() { ++ return lastBlock; ++ } ++ ++ /** ++ * How many anchor heights this range spans. ++ * ++ * @return the span, in anchor heights ++ */ ++ public long anchorHeights() { ++ return anchorHeights; ++ } ++ ++ /** ++ * What was not in force over this range. ++ * ++ * @return the relaxation ++ */ ++ public Relaxation relaxation() { ++ return relaxation; ++ } ++ ++ /** ++ * The seal threshold that WAS in force over this range. ++ * ++ * @return the measured floor, zero when no rule was in force ++ */ ++ public int effectiveMinSeals() { ++ return effectiveMinSeals; ++ } ++ ++ /** ++ * Why this range exists. ++ * ++ * @return the reason, with what was measured ++ */ ++ public String reason() { ++ return reason; ++ } ++ ++ /** ++ * Whether a height falls inside this range. ++ * ++ * @param blockNumber the height being judged ++ * @return true iff the height is within the inclusive bounds ++ */ ++ public boolean covers(final long blockNumber) { ++ return blockNumber >= firstBlock && blockNumber <= lastBlock; ++ } ++ ++ @Override ++ public String toString() { ++ return "[" ++ + firstBlock ++ + ", " ++ + lastBlock ++ + "] " ++ + relaxation ++ + " (" ++ + anchorHeights ++ + " anchor heights, threshold in force " ++ + effectiveMinSeals ++ + ")"; ++ } ++ } ++ ++ private static final List WINDOWS = ++ List.of( ++ new Window( ++ 13_267_824L, ++ 13_268_944L, ++ 36L, ++ Relaxation.EVERYTHING, ++ 0, ++ "2026-08-10: the anchor rules were switched off on the whole validator set to restart " ++ + "a stopped chain, so the headers produced meanwhile carry the client version " ++ + "string in vanityData instead of the anchor digest, and their certificates " ++ + "carry the indices in arrival order rather than sorted. Measured: 36 anchor " ++ + "heights, all 36 affected, all with distinct non-negative indices"), ++ new Window( ++ 13_268_976L, ++ 13_890_544L, ++ 19_425L, ++ Relaxation.THRESHOLD_ONLY, ++ 1, ++ "2026-08-10 onwards: the rules were restored immediately afterwards " ++ + "but with the emergency seal ceiling lowered, because raising the threshold " ++ + "back in one step had stopped the chain a second time. Every header here binds " ++ + "its digest and carries sorted indices; 2,926 of the 19,425 " ++ + "anchor heights in the range simply carry fewer seals than the schedule asks, " ++ + "the fewest being 1")); ++ ++ static { ++ // The list is ordered and disjoint, and each entry's stated span agrees with its own bounds. ++ // This runs at class initialisation so that a future entry which contradicts itself, or which ++ // overlaps its neighbour, fails loudly at startup rather than silently widening what a node ++ // accepts. ++ Window previous = null; ++ for (final Window window : WINDOWS) { ++ if (previous != null && window.firstBlock() <= previous.lastBlock()) { ++ throw new IllegalStateException( ++ "AERE PQ ANCHOR LAPSE: windows must be ordered and disjoint, " ++ + previous ++ + " overlaps " ++ + window); ++ } ++ final long derived = (window.lastBlock() - window.firstBlock()) / ANCHOR_SPACING + 1L; ++ if (derived != window.anchorHeights()) { ++ throw new IllegalStateException( ++ "AERE PQ ANCHOR LAPSE: window " ++ + window ++ + " says it spans " ++ + window.anchorHeights() ++ + " anchor heights, but its bounds at spacing " ++ + ANCHOR_SPACING ++ + " span " ++ + derived); ++ } ++ if (window.relaxation() == Relaxation.EVERYTHING && window.effectiveMinSeals() != 0) { ++ throw new IllegalStateException( ++ "AERE PQ ANCHOR LAPSE: window " + window + " has no rule in force, so no threshold"); ++ } ++ if (window.relaxation() == Relaxation.THRESHOLD_ONLY && window.effectiveMinSeals() < 1) { ++ throw new IllegalStateException( ++ "AERE PQ ANCHOR LAPSE: window " ++ + window ++ + " relaxes only the threshold, so the threshold it replaces it with must be at " ++ + "least 1; a floor of zero would accept an empty certificate where a certificate " ++ + "was in fact required"); ++ } ++ previous = window; ++ } ++ } ++ ++ private PqAnchorLapse() {} ++ ++ /** ++ * Every named range, ordered by first height. ++ * ++ * @return an unmodifiable list, never null, possibly empty ++ */ ++ public static List windows() { ++ return WINDOWS; ++ } ++ ++ /** ++ * Whether the anchor rules were out of force ENTIRELY at this height. ++ * ++ *

This is the single question both anchor rules ask before they judge anything. They ask it ++ * through this one method, for the same reason they share {@code anchorAppliesAt}: two rules that ++ * each decided the boundary their own way could disagree about one header, and a header accepted ++ * by one rule and refused by the other is a chain break. ++ * ++ *

It is deliberately FALSE inside a threshold-only range. There the digest binding and the ++ * ordering did hold and are still required; only {@link #historicMinSeals(long)} moves. ++ * ++ * @param blockNumber the height being judged ++ * @return true iff the height falls inside a range where nothing was in force ++ */ ++ public static boolean isDisarmed(final long blockNumber) { ++ for (final Window window : WINDOWS) { ++ if (window.covers(blockNumber) && window.relaxation() == Relaxation.EVERYTHING) { ++ return true; ++ } ++ } ++ return false; ++ } ++ ++ /** ++ * The seal threshold that was actually in force at this height, when it was lower than the ++ * configured schedule. ++ * ++ *

This is the same lever the fleet itself used at the time, the emergency ceiling, replayed ++ * from a fixed range instead of from a runtime option. A node validating history therefore asks ++ * the certificate for what was asked of the proposer that wrote it, and nothing more. ++ * ++ * @param blockNumber the height being judged ++ * @return the threshold in force, or empty when the configured schedule applies unchanged ++ */ ++ public static OptionalInt historicMinSeals(final long blockNumber) { ++ for (final Window window : WINDOWS) { ++ if (window.covers(blockNumber) && window.relaxation() == Relaxation.THRESHOLD_ONLY) { ++ return OptionalInt.of(window.effectiveMinSeals()); ++ } ++ } ++ return OptionalInt.empty(); ++ } ++ ++ /** ++ * The range covering a height, when there is one. ++ * ++ * @param blockNumber the height being judged ++ * @return the range, or empty ++ */ ++ public static Optional windowCovering(final long blockNumber) { ++ return WINDOWS.stream().filter(w -> w.covers(blockNumber)).findFirst(); ++ } ++ ++ /** ++ * The first height of the earliest named range, or empty when there is none. ++ * ++ * @return the earliest affected height ++ */ ++ public static Optional earliestAffectedBlock() { ++ return WINDOWS.stream().map(Window::firstBlock).min(Comparator.naturalOrder()); ++ } ++} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java new file mode 100755 index 000000000..772560067 @@ -13357,6 +13781,261 @@ index 000000000..08afdba0a + .isEqualTo(1); + } +} +diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java +new file mode 100755 +index 000000000..038d86287 +--- /dev/null ++++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java +@@ -0,0 +1,249 @@ ++/* ++ * 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.List; ++ ++import org.junit.jupiter.api.Test; ++ ++/** ++ * The named historical ranges in which the anchor rules were not fully in force. ++ * ++ *

The bounds are the whole content of the class under test, so they are asserted as LITERALS ++ * rather than through the constants they define. An assertion written as {@code ++ * assertThat(FIRST).isEqualTo(FIRST)} would follow any future edit of the constant and would ++ * therefore never fail, which is the exact shape of a gate that cannot go red. ++ */ ++public class PqAnchorLapseTest { ++ ++ private static final long LAST_ENFORCED_BEFORE = 13_267_792L; ++ ++ private static final long DISARMED_FIRST = 13_267_824L; ++ private static final long DISARMED_LAST = 13_268_944L; ++ ++ private static final long LOWERED_FIRST = 13_268_976L; ++ private static final long LOWERED_LAST = 13_890_544L; ++ ++ private static final long FIRST_FULLY_ENFORCED_AFTER = 13_890_576L; ++ ++ @Test ++ public void theListNamesExactlyTheTwoMeasuredRanges() { ++ // Measured by reading vanityData and the certificate at every anchor height from the activation ++ // height 13,014,000 to the head at the time of measurement, 14,077,000, one height at a time. ++ // The list is a LIST so a third could be added without touching a rule; it has two because the ++ // chain has two. ++ assertThat(PqAnchorLapse.windows()).hasSize(2); ++ ++ final PqAnchorLapse.Window disarmed = PqAnchorLapse.windows().get(0); ++ assertThat(disarmed.firstBlock()).isEqualTo(13_267_824L); ++ assertThat(disarmed.lastBlock()).isEqualTo(13_268_944L); ++ assertThat(disarmed.anchorHeights()).isEqualTo(36L); ++ assertThat(disarmed.relaxation()).isEqualTo(PqAnchorLapse.Relaxation.EVERYTHING); ++ assertThat(disarmed.effectiveMinSeals()).isZero(); ++ assertThat(disarmed.reason()).contains("2026-08-10"); ++ ++ final PqAnchorLapse.Window lowered = PqAnchorLapse.windows().get(1); ++ assertThat(lowered.firstBlock()).isEqualTo(13_268_976L); ++ assertThat(lowered.lastBlock()).isEqualTo(13_890_544L); ++ assertThat(lowered.anchorHeights()).isEqualTo(19_425L); ++ assertThat(lowered.relaxation()).isEqualTo(PqAnchorLapse.Relaxation.THRESHOLD_ONLY); ++ assertThat(lowered.effectiveMinSeals()).isEqualTo(1); ++ } ++ ++ @Test ++ public void theTwoRangesAreAdjacentButDisjoint() { ++ // The second begins at the next anchor height after the first ends. They must not overlap, and ++ // there must be no unnamed anchor height between them, because a height in neither range is ++ // judged strictly and there is no such height here. ++ assertThat(DISARMED_LAST + PqAnchorLapse.ANCHOR_SPACING).isEqualTo(LOWERED_FIRST); ++ assertThat(PqAnchorLapse.windows().get(0).lastBlock()) ++ .isLessThan(PqAnchorLapse.windows().get(1).firstBlock()); ++ } ++ ++ @Test ++ public void theStatedSpansAgreeWithTheBounds() { ++ // 36, not 37: (13,268,944 - 13,267,824) / 32 + 1 = 36. And 19,425 for the second range. The ++ // span is carried in each entry so the two cannot drift apart in silence; this asserts the ++ // arithmetic from the outside as well. ++ for (final PqAnchorLapse.Window window : PqAnchorLapse.windows()) { ++ assertThat((window.lastBlock() - window.firstBlock()) % PqAnchorLapse.ANCHOR_SPACING).isZero(); ++ assertThat((window.lastBlock() - window.firstBlock()) / PqAnchorLapse.ANCHOR_SPACING + 1L) ++ .isEqualTo(window.anchorHeights()); ++ } ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // isDisarmed: TRUE only in the first range, and deliberately FALSE in the second. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void isDisarmedIsTrueOnlyWhereNothingWasInForce() { ++ assertThat(PqAnchorLapse.isDisarmed(DISARMED_FIRST)).isTrue(); ++ assertThat(PqAnchorLapse.isDisarmed(DISARMED_LAST)).isTrue(); ++ ++ // The neighbouring anchor heights, both measured to carry a digest, stay under the strict rules. ++ assertThat(PqAnchorLapse.isDisarmed(LAST_ENFORCED_BEFORE)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(DISARMED_FIRST - 1L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(DISARMED_LAST + 1L)).isFalse(); ++ ++ // And FALSE across the whole second range: there the digest and the ordering did hold, and a ++ // relaxation that leaked into it would throw away the binding on 19,425 anchor heights. ++ assertThat(PqAnchorLapse.isDisarmed(LOWERED_FIRST)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(13_500_016L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(LOWERED_LAST)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(FIRST_FULLY_ENFORCED_AFTER)).isFalse(); ++ } ++ ++ @Test ++ public void everyAnchorHeightOfTheDisarmedRangeIsCovered() { ++ long covered = 0L; ++ for (long height = DISARMED_FIRST; ++ height <= DISARMED_LAST; ++ height += PqAnchorLapse.ANCHOR_SPACING) { ++ assertThat(PqAnchorLapse.isDisarmed(height)) ++ .describedAs("anchor height %d of the disarmed range", height) ++ .isTrue(); ++ covered++; ++ } ++ assertThat(covered).isEqualTo(36L); ++ ++ // The rest of the chain is untouched. ++ assertThat(PqAnchorLapse.isDisarmed(0L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(13_014_000L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(13_000_000L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(14_077_000L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(Long.MAX_VALUE)).isFalse(); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // historicMinSeals: the threshold that was really in force, and only where it was lower. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void historicMinSealsAnswersOnlyInsideTheLoweredRange() { ++ assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST)).hasValue(1); ++ assertThat(PqAnchorLapse.historicMinSeals(13_500_016L)).hasValue(1); ++ assertThat(PqAnchorLapse.historicMinSeals(LOWERED_LAST)).hasValue(1); ++ ++ // One anchor step past the end and the configured schedule applies again, unchanged. This is ++ // the assertion that stops the lowered threshold from becoming permanent. ++ assertThat(PqAnchorLapse.historicMinSeals(FIRST_FULLY_ENFORCED_AFTER)).isEmpty(); ++ assertThat(PqAnchorLapse.historicMinSeals(LOWERED_LAST + 1L)).isEmpty(); ++ assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST - 1L)).isEmpty(); ++ assertThat(PqAnchorLapse.historicMinSeals(LAST_ENFORCED_BEFORE)).isEmpty(); ++ assertThat(PqAnchorLapse.historicMinSeals(14_077_000L)).isEmpty(); ++ assertThat(PqAnchorLapse.historicMinSeals(Long.MAX_VALUE)).isEmpty(); ++ ++ // And it does NOT answer inside the disarmed range: there the rules are skipped whole, so a ++ // threshold would be a second, contradictory way of saying the same thing. ++ assertThat(PqAnchorLapse.historicMinSeals(DISARMED_FIRST)).isEmpty(); ++ assertThat(PqAnchorLapse.historicMinSeals(DISARMED_LAST)).isEmpty(); ++ } ++ ++ @Test ++ public void theFloorInsideTheLoweredRangeIsOneAndNotZero() { ++ // Measured: 2,926 of the 19,425 anchor heights in that range carry fewer seals than the ++ // schedule asks, the counts seen are 1 and 2, and NOT ONE carries zero. A floor of zero would ++ // therefore accept an empty certificate at a height where a certificate was in fact required, ++ // which is weaker than the history needs. ++ assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST)).hasValue(1); ++ assertThat(PqAnchorLapse.windows().get(1).effectiveMinSeals()).isPositive(); ++ } ++ ++ @Test ++ public void windowCoveringNamesTheRangeOrNothing() { ++ assertThat(PqAnchorLapse.windowCovering(DISARMED_FIRST)).isPresent(); ++ assertThat(PqAnchorLapse.windowCovering(DISARMED_FIRST).get().lastBlock()) ++ .isEqualTo(DISARMED_LAST); ++ assertThat(PqAnchorLapse.windowCovering(LOWERED_FIRST)).isPresent(); ++ assertThat(PqAnchorLapse.windowCovering(LOWERED_FIRST).get().relaxation()) ++ .isEqualTo(PqAnchorLapse.Relaxation.THRESHOLD_ONLY); ++ assertThat(PqAnchorLapse.windowCovering(FIRST_FULLY_ENFORCED_AFTER)).isEmpty(); ++ assertThat(PqAnchorLapse.earliestAffectedBlock()).contains(DISARMED_FIRST); ++ } ++ ++ @Test ++ public void theDisarmedRangeIsTheSameOBJECTTheOrderingExceptionUses() { ++ // A second copy of these bounds anywhere else is a value that diverges. PqAnchor used to carry ++ // them as its own literals; it now derives them, and this is the assertion that says so. ++ assertThat(PqAnchor.UNORDERED_WINDOW_FIRST).isEqualTo(DISARMED_FIRST); ++ assertThat(PqAnchor.UNORDERED_WINDOW_LAST).isEqualTo(DISARMED_LAST); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // The self-checks can say no. A guard that has never refused anything cannot be believed. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void aRangeThatContradictsItselfIsRefusedRatherThanAccepted() { ++ assertThatThrownBy( ++ () -> ++ new PqAnchorLapse.Window( ++ 100L, 99L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "backwards")) ++ .isInstanceOf(IllegalArgumentException.class) ++ .hasMessageContaining("non-empty range"); ++ assertThatThrownBy( ++ () -> ++ new PqAnchorLapse.Window( ++ -1L, 100L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "negative")) ++ .isInstanceOf(IllegalArgumentException.class); ++ assertThatThrownBy( ++ () -> ++ new PqAnchorLapse.Window( ++ 100L, 200L, 0L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "no heights")) ++ .isInstanceOf(IllegalArgumentException.class) ++ .hasMessageContaining("at least one anchor height"); ++ assertThatThrownBy( ++ () -> ++ new PqAnchorLapse.Window( ++ 100L, 200L, 4L, PqAnchorLapse.Relaxation.THRESHOLD_ONLY, -1, "negative floor")) ++ .isInstanceOf(IllegalArgumentException.class) ++ .hasMessageContaining("cannot be negative"); ++ } ++ ++ @Test ++ public void aRangeCoversItsOwnBoundsAndNothingOutsideThem() { ++ final PqAnchorLapse.Window window = ++ new PqAnchorLapse.Window( ++ 1_000L, 1_032L, 2L, PqAnchorLapse.Relaxation.THRESHOLD_ONLY, 1, "probe"); ++ assertThat(window.covers(1_000L)).isTrue(); ++ assertThat(window.covers(1_016L)).isTrue(); ++ assertThat(window.covers(1_032L)).isTrue(); ++ assertThat(window.covers(999L)).isFalse(); ++ assertThat(window.covers(1_033L)).isFalse(); ++ assertThat(window.toString()).contains("1000").contains("1032").contains("THRESHOLD_ONLY"); ++ } ++ ++ @Test ++ public void theListIsUnmodifiable() { ++ // A range a node could widen at runtime would let a future lapse pass unnoticed. ++ final List windows = PqAnchorLapse.windows(); ++ assertThatThrownBy( ++ () -> ++ windows.add( ++ new PqAnchorLapse.Window( ++ 1L, 2L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "injected"))) ++ .isInstanceOf(UnsupportedOperationException.class); ++ } ++ ++ @Test ++ public void theRangesBelongToThisChainAndAreDeclaredSo() { ++ assertThat(PqAnchorLapse.CHAIN_ID).isEqualTo(2800L); ++ assertThat(PqAnchorLapse.ANCHOR_SPACING).isEqualTo(32L); ++ } ++} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java new file mode 100755 index 000000000..53917eecb @@ -22646,10 +23325,10 @@ index 000000000..b1a3ba265 +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java new file mode 100755 -index 000000000..c79a884ad +index 000000000..ce646450c --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java -@@ -0,0 +1,207 @@ +@@ -0,0 +1,265 @@ +/* + * Copyright contributors to Besu / Aere Network. + * @@ -22671,6 +23350,7 @@ index 000000000..c79a884ad +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.PqAnchorLapse; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; @@ -22780,6 +23460,16 @@ index 000000000..c79a884ad + return true; + } + ++ // SECOND GATE: the named historical windows in which these rules were not in force. Inside one ++ // of them vanityData carries the client's version string rather than a digest, so there is no ++ // digest to match and no recomputation can produce one; refusing would stop this node forever ++ // at a height the whole network holds. See PqAnchorLapse for what happened, how the bounds were ++ // measured, and why an exception written anywhere other than validation moves the error instead ++ // of ending it. ++ if (PqAnchorLapse.isDisarmed(number)) { ++ return acceptAsWrittenInsideLapse(header, number); ++ } ++ + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + @@ -22845,6 +23535,53 @@ index 000000000..c79a884ad + } + } + ++ /** ++ * Accept a header from a named unenforced window as it was written, after the one check that is ++ * not relaxed at any height. ++ * ++ *

Distinctness is kept and the difference is the whole reason this is not a bare {@code return ++ * true}: sortedness fixes ONE accepted order for a given set, while distinctness is what makes ++ * "repeat one seal to inflate the count" unrepresentable in the grammar of the format. Every ++ * header of the measured window has distinct, non-negative indices, so this admits exactly the ++ * history that exists and nothing weaker. ++ * ++ *

Decoding must still succeed. A header this rule cannot parse has not been shown to be one of ++ * the headers the window is about, so it is refused here as everywhere else in this file. ++ * ++ * @param header the header being judged ++ * @param number its height ++ * @return true iff the carried certificate decodes and its indices are distinct and non-negative ++ */ ++ private boolean acceptAsWrittenInsideLapse(final BlockHeader header, final long number) { ++ try { ++ final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); ++ final List certificate = List.copyOf(extraData.getFalconSeals()); ++ if (!PqAnchor.hasDistinctNonNegativeIndices(certificate)) { ++ LOG.warn( ++ "AERE PQ ANCHOR (R1): block {} REJECTED - it falls inside the historical range {} in " ++ + "which the anchor rules were not in force, but its certificate repeats a " ++ + "validator index or carries a negative one. That is not a shape any header of " ++ + "that range has, and it is the one property the exception does not relax.", ++ number, ++ PqAnchorLapse.windowCovering(number).orElse(null)); ++ return false; ++ } ++ LOG.debug( ++ "AERE PQ ANCHOR (R1): block {} accepted as written; it falls inside the historical range " ++ + "{} in which the anchor rules were not in force, so it carries no digest to bind.", ++ number, ++ PqAnchorLapse.windowCovering(number).orElse(null)); ++ return true; ++ } catch (final Exception e) { ++ LOG.warn( ++ "AERE PQ ANCHOR (R1): block {} REJECTED - it falls inside a historical unenforced range " ++ + "but its extraData could not be decoded: {}", ++ number, ++ e.toString()); ++ return false; ++ } ++ } ++ + @Override + public boolean includeInLightValidation() { + return true; @@ -22859,10 +23596,10 @@ index 000000000..c79a884ad +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java new file mode 100755 -index 000000000..a4350747a +index 000000000..9be0c7b33 --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java -@@ -0,0 +1,329 @@ +@@ -0,0 +1,403 @@ +/* + * Copyright contributors to Besu / Aere Network. + * @@ -22885,6 +23622,7 @@ index 000000000..a4350747a +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.PqAnchorLapse; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.datatypes.Address; @@ -22895,6 +23633,7 @@ index 000000000..a4350747a +import java.util.Collection; +import java.util.HashSet; +import java.util.List; ++import java.util.OptionalInt; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes32; @@ -22990,6 +23729,15 @@ index 000000000..a4350747a + return true; + } + ++ // SECOND GATE, and the SAME method R1 asks, for the same reason both rules share ++ // anchorAppliesAt: if the two rules each decided this boundary their own way, a header accepted ++ // by one and refused by the other would be a chain break. Inside a named window the threshold ++ // was not in force when the header was written and the indices were written in arrival order, ++ // so neither can be required of it now. ++ if (PqAnchorLapse.isDisarmed(number)) { ++ return acceptAsWrittenInsideLapse(header, number); ++ } ++ + try { + if (parent == null) { + LOG.warn( @@ -23027,7 +23775,26 @@ index 000000000..a4350747a + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final List certificate = List.copyOf(extraData.getFalconSeals()); + final int k = certificate.size(); -+ final int required = config.minSealsAt(number); ++ ++ // THE THRESHOLD THIS HEIGHT WAS ACTUALLY JUDGED BY. Normally the configured schedule. Inside ++ // a named historical range it is the LOWER of the schedule and the emergency ceiling that was ++ // in force when these headers were written, which is the same lever the fleet itself used at ++ // the time, replayed from a fixed range instead of from a runtime option. Asking a header for ++ // more than was asked of the proposer that wrote it refuses history that no node can ++ // reproduce; asking for the ceiling rather than for nothing keeps the strongest claim the ++ // range supports, and the ceiling is a measured floor, never zero. See PqAnchorLapse. ++ int required = config.minSealsAt(number); ++ final OptionalInt inForce = PqAnchorLapse.historicMinSeals(number); ++ if (inForce.isPresent() && inForce.getAsInt() < required) { ++ LOG.debug( ++ "AERE PQ ANCHOR (R2): block {} sits in the historical range {}, so the threshold " ++ + "applied is the {} that was in force then, not the {} the schedule asks for.", ++ number, ++ PqAnchorLapse.windowCovering(number).orElse(null), ++ inForce.getAsInt(), ++ required); ++ required = inForce.getAsInt(); ++ } + + if (k < required) { + LOG.warn( @@ -23151,6 +23918,50 @@ index 000000000..a4350747a + } + + /** ++ * Accept a header from a named unenforced window as it was written, after the one check that is ++ * not relaxed at any height. ++ * ++ *

The threshold is not applied here and neither is sortedness, because neither was applied ++ * when these headers were written; applying them now would refuse history the whole network ++ * holds. Distinctness IS applied, for the reason set out in {@link PqAnchor#hasAcceptableIndices} ++ * and in {@link PqAnchorLapse}: it is the part that is load bearing against an attacker rather ++ * than against ambiguity, and it is measured true on every header of the window. ++ * ++ * @param header the header being judged ++ * @param number its height ++ * @return true iff the carried certificate decodes and its indices are distinct and non-negative ++ */ ++ private boolean acceptAsWrittenInsideLapse(final BlockHeader header, final long number) { ++ try { ++ final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); ++ final List certificate = List.copyOf(extraData.getFalconSeals()); ++ if (!PqAnchor.hasDistinctNonNegativeIndices(certificate)) { ++ LOG.warn( ++ "AERE PQ ANCHOR (R2): block {} REJECTED - it falls inside the historical range {} in " ++ + "which the anchor rules were not in force, but its certificate repeats a " ++ + "validator index or carries a negative one, which no header of that range does.", ++ number, ++ PqAnchorLapse.windowCovering(number).orElse(null)); ++ return false; ++ } ++ LOG.debug( ++ "AERE PQ ANCHOR (R2): block {} accepted as written with {} seal(s); it falls inside the " ++ + "historical range {} in which neither the threshold nor the ordering was in force.", ++ number, ++ certificate.size(), ++ PqAnchorLapse.windowCovering(number).orElse(null)); ++ return true; ++ } catch (final Exception e) { ++ LOG.warn( ++ "AERE PQ ANCHOR (R2): block {} REJECTED - it falls inside a historical unenforced range " ++ + "but its extraData could not be decoded: {}", ++ 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 @@ -24273,6 +25084,560 @@ index 000000000..7f03e3667 + return header(honest.getNumber(), honest.getParentHash(), tampered); + } +} +diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java +new file mode 100755 +index 000000000..9451ec7b7 +--- /dev/null ++++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java +@@ -0,0 +1,548 @@ ++/* ++ * 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.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.PqAnchorLapse; ++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 java.util.ArrayList; ++import java.util.Collection; ++import java.util.HashMap; ++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.junit.jupiter.api.Test; ++import org.mockito.quality.Strictness; ++ ++/** ++ * The two historical ranges in which the anchor rules were not fully in force, as the two rules see ++ * them. ++ * ++ *

The configuration here is the one the live chain runs, not the small synthetic one the other ++ * rule tests use, because the whole subject is a set of REAL heights: the activation height, the ++ * 32-block anchor spacing and the threshold schedule all have to be the production ones, or the ++ * heights under test are not anchor heights at all and every assertion below would pass on a rule ++ * that does nothing. {@link #everyHeightUnderTestIsAnAnchorHeightOfTheLiveConfiguration()} is the ++ * positive control for exactly that. ++ * ++ *

Every acceptance in this file is paired with the same header shape at a height where it must ++ * still be refused, and every refusal is paired with an honest header at that same height, which ++ * must still be accepted. Without the first pairing the tests would pass on a rule that accepts ++ * everything; without the second they would pass on a rule that refuses everything. ++ */ ++public class PqAnchorDisarmedWindowTest { ++ ++ /** The live activation height H. */ ++ private static final long H_LIVE = 13_014_000L; ++ ++ /** The live anchor spacing. */ ++ private static final int INTERVAL = 32; ++ ++ /** The last anchor height fully under the rules before the interruption. */ ++ private static final long BEFORE = 13_267_792L; ++ ++ /** First height of the range in which nothing was in force. */ ++ private static final long OFF_FIRST = 13_267_824L; ++ ++ /** A height in the middle of that range. */ ++ private static final long OFF_MIDDLE = 13_268_016L; ++ ++ /** Last height of that range. */ ++ private static final long OFF_LAST = 13_268_944L; ++ ++ /** First height of the range in which only the threshold was lowered. */ ++ private static final long LOW_FIRST = 13_268_976L; ++ ++ /** A height in the middle of that range. */ ++ private static final long LOW_MIDDLE = 13_500_016L; ++ ++ /** Last height of that range. */ ++ private static final long LOW_LAST = 13_890_544L; ++ ++ /** The first anchor height fully under the rules again. */ ++ private static final long AFTER = 13_890_576L; ++ ++ /** ++ * The vanityData the headers of the first range actually carry: the client's version string, ++ * which is what a node writes when the anchor is switched off. Read off a live header of the ++ * range. ++ */ ++ private static final Bytes CLIENT_VANITY = ++ Bytes.fromHexString("0x00000000000000626573752032362e372d646576656c6f702d64323033323031"); ++ ++ /** Signer indices exactly as one header of the first range carries them: distinct, not sorted. */ ++ private static final List ARRIVAL_ORDER = List.of(2, 6, 1); ++ ++ /** A second measured arrival order, from another header of the same range. */ ++ private static final List ARRIVAL_ORDER_2 = List.of(1, 3, 6, 0); ++ ++ private final PqAnchorConfig live = ++ new PqAnchorConfig( ++ CHAIN_ID, ++ H_LIVE, ++ Map.of(H_LIVE, 0, 13_034_000L, 3), ++ OptionalInt.empty(), ++ false, ++ OptionalInt.empty(), ++ OptionalInt.of(INTERVAL)); ++ ++ private final LocalRegistry registry = new LocalRegistry(); ++ private final PqAnchorDigestRule r1 = new PqAnchorDigestRule(live); ++ private final PqAnchorSealsRule r2 = new PqAnchorSealsRule(live, registry); ++ ++ // ----------------------------------------------------------------------------------------------- ++ // The heights under test really are anchor heights. Without this the file measures nothing. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void everyHeightUnderTestIsAnAnchorHeightOfTheLiveConfiguration() { ++ for (final long height : ++ List.of(BEFORE, OFF_FIRST, OFF_MIDDLE, OFF_LAST, LOW_FIRST, LOW_MIDDLE, LOW_LAST, AFTER)) { ++ assertThat(live.anchorAppliesAt(height)) ++ .describedAs("height %d must be one the rules judge, or this file proves nothing", height) ++ .isTrue(); ++ assertThat(live.minSealsAt(height)) ++ .describedAs("the schedule asks for three seals at height %d", height) ++ .isEqualTo(3); ++ } ++ assertThat(PqAnchorLapse.isDisarmed(OFF_FIRST)).isTrue(); ++ assertThat(PqAnchorLapse.isDisarmed(OFF_LAST)).isTrue(); ++ assertThat(PqAnchorLapse.isDisarmed(BEFORE)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(LOW_FIRST)).isFalse(); ++ assertThat(PqAnchorLapse.historicMinSeals(LOW_FIRST)).hasValue(1); ++ assertThat(PqAnchorLapse.historicMinSeals(AFTER)).isEmpty(); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // RANGE 1, nothing in force: a header of the shape the chain holds is accepted by both rules. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void r1AcceptsAHeaderOfTheFirstRangeAsWritten() { ++ for (final long height : List.of(OFF_FIRST, OFF_MIDDLE, OFF_LAST)) { ++ final BlockHeader parent = parentHeader(height - 1L); ++ final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); ++ assertThat(r1.validate(block, parent)) ++ .describedAs( ++ "height %d carries the client version string in vanityData and a certificate in " ++ + "arrival order; that is what the chain holds, and a node syncing from genesis " ++ + "must be able to pass it", ++ height) ++ .isTrue(); ++ } ++ } ++ ++ @Test ++ public void r2AcceptsAHeaderOfTheFirstRangeAsWritten() { ++ for (final long height : List.of(OFF_FIRST, OFF_MIDDLE, OFF_LAST)) { ++ final BlockHeader parent = parentHeader(height - 1L); ++ final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); ++ assertThat(r2.validate(block, parent, contextWith(VALIDATORS))) ++ .describedAs("height %d must pass the seals rule too, or the node stops 32 blocks on", height) ++ .isTrue(); ++ } ++ } ++ ++ @Test ++ public void bothRulesAcceptTheSecondMeasuredArrivalOrder() { ++ final BlockHeader parent = parentHeader(OFF_MIDDLE - 1L); ++ final BlockHeader block = unenforcedHeader(OFF_MIDDLE, parent, ARRIVAL_ORDER_2); ++ assertThat(r1.validate(block, parent)).isTrue(); ++ assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); ++ } ++ ++ @Test ++ public void insideTheFirstRangeTheThresholdIsNotAppliedEither() { ++ // The threshold is one of the things that was switched off, so a header of this range may carry ++ // fewer seals than K. The schedule asks three; this certificate carries one. ++ final BlockHeader parent = parentHeader(OFF_FIRST - 1L); ++ final BlockHeader block = unenforcedHeader(OFF_FIRST, parent, List.of(4)); ++ assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); ++ } ++ ++ @Test ++ public void insideTheFirstRangeAnHonestHeaderIsStillAccepted() { ++ // The exception must not break the ordinary case: a correctly written header that happens to ++ // fall inside the range is accepted too, so nothing has to be re-produced. ++ final BlockHeader parent = parentHeader(OFF_FIRST - 1L); ++ final BlockHeader block = honestBlock(OFF_FIRST, parent, List.of(0, 1, 2)); ++ assertThat(r1.validate(block, parent)).isTrue(); ++ assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // RANGE 2, only the threshold lowered: the digest and the ordering are STILL required, and the ++ // short certificate is accepted down to the floor that was really in force, and no further. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void insideTheSecondRangeTheDigestIsStillRequired() { ++ // This is the assertion that stops the first range's relaxation from leaking into the second. ++ // If it ever passes, the binding has been thrown away on 19,425 anchor heights. ++ for (final long height : List.of(LOW_FIRST, LOW_MIDDLE, LOW_LAST)) { ++ final BlockHeader parent = parentHeader(height - 1L); ++ final BlockHeader noDigest = unenforcedHeader(height, parent, List.of(0, 1, 2)); ++ assertThat(r1.validate(noDigest, parent)) ++ .describedAs("height %d is inside the lowered-threshold range, where the digest DID hold", height) ++ .isFalse(); ++ ++ // Positive control at the same height. ++ assertThat(r1.validate(honestBlock(height, parent, List.of(0, 1, 2)), parent)).isTrue(); ++ } ++ } ++ ++ @Test ++ public void insideTheSecondRangeTheOrderingIsStillRequired() { ++ final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ final BlockHeader unsorted = digestBoundButUnsorted(LOW_MIDDLE, parent, ARRIVAL_ORDER); ++ assertThat(r2.validate(unsorted, parent, context)).isFalse(); ++ assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(1, 2, 6)), parent, context)) ++ .isTrue(); ++ } ++ ++ @Test ++ public void insideTheSecondRangeAShortCertificateIsAccepted() { ++ // Measured: 2,926 of the 19,425 anchor heights of this range carry one or two seals where the ++ // schedule asks three, because the fleet was running with the emergency ceiling lowered. ++ for (final long height : List.of(LOW_FIRST, LOW_MIDDLE, LOW_LAST)) { ++ final BlockHeader parent = parentHeader(height - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ assertThat(live.minSealsAt(height)).isEqualTo(3); ++ ++ assertThat(r2.validate(honestBlock(height, parent, List.of(0, 1)), parent, context)) ++ .describedAs("two seals at height %d, the count actually written there", height) ++ .isTrue(); ++ assertThat(r2.validate(honestBlock(height, parent, List.of(5)), parent, context)) ++ .describedAs("one seal at height %d, the lowest count measured anywhere in the range", height) ++ .isTrue(); ++ } ++ } ++ ++ @Test ++ public void insideTheSecondRangeAnEmptyCertificateIsStillRefused() { ++ // The floor is ONE, not zero, because one is the ceiling that was actually in force and no ++ // header of the range carries zero. A floor of zero would accept an empty certificate where a ++ // certificate was in fact required, which is weaker than the history needs. ++ final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of()), parent, context)).isFalse(); ++ assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(0)), parent, context)).isTrue(); ++ } ++ ++ @Test ++ public void insideTheSecondRangeAnInvalidSignatureIsStillRefused() { ++ // Only the COUNT moves. Every seal still has to verify, and still has to belong to a validator ++ // of the parent. ++ final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ final List bad = List.of(new FalconSeal(0, Bytes.repeat((byte) 0x5a, 655))); ++ final Bytes32 digest = ++ PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), bad); ++ final BlockHeader block = header(LOW_MIDDLE, parent.getHash(), extraData(digest, bad)); ++ assertThat(r2.validate(block, parent, context)).isFalse(); ++ assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(0)), parent, context)).isTrue(); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // OUTSIDE both ranges: nothing changed at all. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void outsideBothRangesAHeaderWithNoDigestIsStillRefusedByR1() { ++ for (final long height : List.of(BEFORE, LOW_FIRST, AFTER)) { ++ final BlockHeader parent = parentHeader(height - 1L); ++ final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); ++ assertThat(r1.validate(block, parent)) ++ .describedAs( ++ "height %d is outside the range where nothing was in force, so a header with no " ++ + "digest must still stop this node; that is what a NEW lapse looks like", ++ height) ++ .isFalse(); ++ ++ final BlockHeader honest = honestBlock(height, parent, List.of(0, 1, 2)); ++ assertThat(r1.validate(honest, parent)) ++ .describedAs("an honest header at height %d is still accepted", height) ++ .isTrue(); ++ } ++ } ++ ++ @Test ++ public void outsideBothRangesTheSameShapeIsStillRefusedByR2() { ++ for (final long height : List.of(BEFORE, LOW_FIRST, AFTER)) { ++ final BlockHeader parent = parentHeader(height - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); ++ assertThat(r2.validate(block, parent, context)) ++ .describedAs("height %d is outside the disarmed range, so unsorted indices are refused", height) ++ .isFalse(); ++ ++ final BlockHeader honest = honestBlock(height, parent, List.of(0, 1, 2)); ++ assertThat(r2.validate(honest, parent, context)) ++ .describedAs("an honest header at height %d is still accepted", height) ++ .isTrue(); ++ } ++ } ++ ++ @Test ++ public void afterBothRangesTheFullThresholdIsBack() { ++ // One anchor step past the end of the lowered range the schedule applies again, unchanged. ++ // This is the assertion that stops the lowered threshold from becoming permanent. ++ final BlockHeader parent = parentHeader(AFTER - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ assertThat(live.minSealsAt(AFTER)).isEqualTo(3); ++ assertThat(r2.validate(honestBlock(AFTER, parent, List.of(0, 1)), parent, context)).isFalse(); ++ assertThat(r2.validate(honestBlock(AFTER, parent, List.of(0, 1, 2)), parent, context)).isTrue(); ++ } ++ ++ @Test ++ public void wellBeyondTheRangesNothingIsRelaxed() { ++ final long far = 14_077_008L; ++ assertThat(live.anchorAppliesAt(far)).isTrue(); ++ final BlockHeader parent = parentHeader(far - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ assertThat(r1.validate(unenforcedHeader(far, parent, ARRIVAL_ORDER), parent)).isFalse(); ++ assertThat(r2.validate(honestBlock(far, parent, List.of(0, 1)), parent, context)).isFalse(); ++ assertThat(r2.validate(honestBlock(far, parent, List.of(0, 1, 2)), parent, context)).isTrue(); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // The bounds are exact, to one anchor step and to one block. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void theBoundsAreExactToOneAnchorStep() { ++ assertThat(BEFORE + INTERVAL).isEqualTo(OFF_FIRST); ++ assertThat(OFF_LAST + INTERVAL).isEqualTo(LOW_FIRST); ++ assertThat(LOW_LAST + INTERVAL).isEqualTo(AFTER); ++ ++ final BlockHeader beforeParent = parentHeader(BEFORE - 1L); ++ assertThat(r1.validate(unenforcedHeader(BEFORE, beforeParent, ARRIVAL_ORDER), beforeParent)) ++ .isFalse(); ++ ++ final BlockHeader firstParent = parentHeader(OFF_FIRST - 1L); ++ assertThat(r1.validate(unenforcedHeader(OFF_FIRST, firstParent, ARRIVAL_ORDER), firstParent)) ++ .isTrue(); ++ ++ final BlockHeader lastParent = parentHeader(OFF_LAST - 1L); ++ assertThat(r1.validate(unenforcedHeader(OFF_LAST, lastParent, ARRIVAL_ORDER), lastParent)) ++ .isTrue(); ++ ++ final BlockHeader lowParent = parentHeader(LOW_FIRST - 1L); ++ assertThat(r1.validate(unenforcedHeader(LOW_FIRST, lowParent, ARRIVAL_ORDER), lowParent)) ++ .isFalse(); ++ ++ // And the far end of the lowered range, to one anchor step, on the threshold. ++ final BlockHeader lowLastParent = parentHeader(LOW_LAST - 1L); ++ final ProtocolContext context = contextWith(VALIDATORS); ++ assertThat(r2.validate(honestBlock(LOW_LAST, lowLastParent, List.of(0)), lowLastParent, context)) ++ .isTrue(); ++ final BlockHeader afterParent = parentHeader(AFTER - 1L); ++ assertThat(r2.validate(honestBlock(AFTER, afterParent, List.of(0)), afterParent, context)) ++ .isFalse(); ++ } ++ ++ @Test ++ public void theBoundsAreExactToOneBlockOnBothRules() { ++ // The heights either side of the bounds are not anchor heights, so both rules stand down there ++ // for the ordinary reason. Asserted so that "accepted" at OFF_FIRST - 1 is not mistaken for the ++ // exception having leaked one block down. ++ assertThat(live.anchorAppliesAt(OFF_FIRST - 1L)).isFalse(); ++ assertThat(live.anchorAppliesAt(OFF_LAST + 1L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(OFF_FIRST - 1L)).isFalse(); ++ assertThat(PqAnchorLapse.isDisarmed(OFF_LAST + 1L)).isFalse(); ++ assertThat(PqAnchorLapse.historicMinSeals(LOW_LAST + 1L)).isEmpty(); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // What the exception deliberately does NOT relax, at any height. ++ // ----------------------------------------------------------------------------------------------- ++ ++ @Test ++ public void insideTheFirstRangeARepeatedIndexIsStillRefusedByBothRules() { ++ // Sortedness fixes one accepted order for a given set; distinctness is what makes "repeat one ++ // seal to inflate the count" unrepresentable. Measured on every header of the range: distinct ++ // in all of them. So this refusal costs nothing and keeps the attack unrepresentable. ++ final BlockHeader parent = parentHeader(OFF_FIRST - 1L); ++ final BlockHeader repeated = unenforcedHeader(OFF_FIRST, parent, List.of(2, 6, 2)); ++ assertThat(r1.validate(repeated, parent)).isFalse(); ++ assertThat(r2.validate(repeated, parent, contextWith(VALIDATORS))).isFalse(); ++ ++ // Positive control: the same rule at the same height accepts the distinct version. ++ final BlockHeader distinct = unenforcedHeader(OFF_FIRST, parent, ARRIVAL_ORDER); ++ assertThat(r1.validate(distinct, parent)).isTrue(); ++ assertThat(r2.validate(distinct, parent, contextWith(VALIDATORS))).isTrue(); ++ } ++ ++ @Test ++ public void insideTheFirstRangeAnUndecodableHeaderIsStillRefused() { ++ final BlockHeader parent = parentHeader(OFF_FIRST - 1L); ++ final BlockHeader garbage = ++ header(OFF_FIRST, parent.getHash(), Bytes.fromHexString("0xdeadbeef")); ++ assertThat(r1.validate(garbage, parent)).isFalse(); ++ assertThat(r2.validate(garbage, parent, contextWith(VALIDATORS))).isFalse(); ++ } ++ ++ @Test ++ public void belowTheActivationHeightNothingIsJudgedAtAll() { ++ // The ranges sit far above H, so this only restates the height gate; it is here because a ++ // future edit that moved the range gates ABOVE the height gate would make the rules decode ++ // headers on the whole existing chain, which is the property that lets this binary be warmed. ++ final BlockHeader parent = parentHeader(H_LIVE - 2L); ++ final BlockHeader block = unenforcedHeader(H_LIVE - 1L, parent, ARRIVAL_ORDER); ++ assertThat(r1.validate(block, parent)).isTrue(); ++ assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); ++ } ++ ++ // ----------------------------------------------------------------------------------------------- ++ // Helpers. ++ // ----------------------------------------------------------------------------------------------- ++ ++ /** ++ * A header of the shape the first range holds: the client version string in vanityData instead of ++ * a digest, and the certificate written in the order the commits arrived. ++ */ ++ private BlockHeader unenforcedHeader( ++ final long number, final BlockHeader parent, final List indicesInArrivalOrder) { ++ return header( ++ number, parent.getHash(), extraData(CLIENT_VANITY, sealsFor(parent, indicesInArrivalOrder))); ++ } ++ ++ /** A header whose digest is correctly bound but whose indices are left in arrival order. */ ++ private BlockHeader digestBoundButUnsorted( ++ final long number, final BlockHeader parent, final List indicesInArrivalOrder) { ++ final List seals = sealsFor(parent, indicesInArrivalOrder); ++ final Bytes32 digest = ++ PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), seals); ++ return header(number, parent.getHash(), extraData(digest, seals)); ++ } ++ ++ /** A header an honest proposer under the armed rules writes: digest bound, indices sorted. */ ++ private BlockHeader honestBlock( ++ final long number, final BlockHeader parent, final List indices) { ++ final List sorted = PqAnchor.sortedByIndex(sealsFor(parent, indices)); ++ final Bytes32 digest = ++ PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), sorted); ++ return header(number, parent.getHash(), extraData(digest, sorted)); ++ } ++ ++ private List sealsFor(final BlockHeader parent, final List indices) { ++ // One return, one mutability. An early return of an immutable empty list here is what ++ // errorprone's MixedMutabilityReturnType refuses, and it is refusing something real: the empty ++ // case is not special, it is the case where the loop runs zero times. ++ final Bytes32 message = ++ PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); ++ final List seals = new ArrayList<>(); ++ for (final int index : indices) { ++ seals.add(new FalconSeal(index, LocalRegistry.sign(index, message))); ++ } ++ return seals; ++ } ++ ++ private ProtocolContext contextWith(final Collection

parentValidators) { ++ final ValidatorProvider validatorProvider = ++ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); ++ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(parentValidators); ++ final BftContext bftContext = ++ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); ++ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); ++ when(bftContext.as(any())).thenReturn(bftContext); ++ return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); ++ } ++ ++ /** ++ * A registry of the same shape as the Falcon one, with a signature that is a deterministic ++ * function of the index AND the message, so a seal replayed at another height fails exactly as a ++ * real verification would. It exists so that the positive controls are real acceptances rather ++ * than acceptances bought by a registry that agrees to everything. ++ */ ++ private static final class LocalRegistry implements PqSignerRegistry { ++ ++ private final Map binding = new HashMap<>(); ++ ++ private LocalRegistry() { ++ for (int i = 0; i < VALIDATORS.size(); i++) { ++ binding.put(i, VALIDATORS.get(i)); ++ } ++ } ++ ++ static Bytes sign(final int index, 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) & 0xFF); ++ } ++ return Bytes.wrap(out); ++ } ++ ++ @Override ++ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { ++ return binding.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, message)); ++ } ++ ++ @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 String toString() { ++ return "LocalRegistry"; ++ } ++ } ++} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java new file mode 100755 index 000000000..3bf64c2a5