Anchor corpus at the live overlay: hybrid v2 certificate (Falcon-512 + SLH-DSA-SHA2-128s), in-tree SLH-DSA engine on the JDK digest (Bouncy Castle licence alongside), seal-store fix (D-339), anchor interval schedule; docs and SPEC 1.2.0 state the arming heights measured on the fleet on 2026-09-05

This commit is contained in:
Aere Network 2026-09-05 07:48:07 +03:00
parent 971754aad9
commit 32ca7453e0
62 changed files with 5831 additions and 164 deletions

View File

@ -16,12 +16,16 @@ consensus post-quantum, and nothing in the precompile patches touches consensus.
Consensus on chain 2800 is **hybrid post-quantum in a precise and limited sense**, by a separate
mechanism: the QBFT header anchor in `anchor/` and `patches/0003`. Since 2026-08-14 every
32nd block (an anchor block) must carry, under its block hash, a certificate of at least three
valid Falcon-512 validator seals (f+1 of nine; eight or nine are carried in practice), and a node
does not finalize an anchor block without it. Raised at block 14,961,456 (August 21, 2026): the
enforced minimum is now **six of nine, a full 2f+1 quorum**; the schedule step is
`aere.pq.anchorMinSeals=13014000:0,13034000:3,14961456:6`, uniform on all nine validators. Classical secp256k1 ECDSA QBFT still finalizes every
block; validator identity and QBFT message transport are classical. Correction 2026-08-19: the
32nd block (an anchor block) must carry, under its block hash, a certificate of valid post-quantum
validator seals, and a node does not finalize an anchor block without it: at least three Falcon-512
seals at first (f+1 of nine), **six of nine, a full 2f+1 quorum, since block 14,961,456** (August 21,
2026), and since block 17,047,600 (2026-09-04) a **hybrid** certificate of Falcon-512 AND
SLH-DSA-SHA2-128s seals, six of nine of each scheme. From block 17,225,968 (schedule set on all nine
validators on 2026-09-05) the anchor falls on every 128th block instead of every 32nd. Classical
secp256k1 ECDSA QBFT still finalizes every block and validator identity is classical; the QBFT
messages themselves carry post-quantum seals of their authors on the live fleet (emission since
blocks 16,620,000 to 16,690,000), with refusal of unsealed messages armed for blocks 17,250,000 to
17,700,000 (set 2026-09-05; see `anchor/README.md`, the arming table). Correction 2026-08-19: the
per-block Falcon quorum rule armed at block 14,050,000 (`aere.falcon.forkBlock`) is, in the shipped
code, retired in favour of the anchor rules from block 13,014,000 and changed no enforcement, so
the earlier statement that from that height every block required a 2f+1 Falcon quorum to finalize

48
SPEC.md
View File

@ -1,6 +1,6 @@
# Aere Network chain 2800: full protocol specification
Version: 1.1.0, written 2026-08-25, amended 2026-09-02 (section 2.6, D8, G11). Scope: everything a third party needs to implement a client
Version: 1.2.0, written 2026-08-25, amended 2026-09-02 (section 2.6, D8, G11) and 2026-09-05 (sections 2.6 and 3.1: the message-layer arming heights, the v2 hybrid certificate, the anchor interval schedule). Scope: everything a third party needs to implement a client
that follows chain 2800 from block 0 to the live head and validates what the network validates,
without reading Aere source code. It specifies the follower (validation) protocol completely; it
describes seal production only where a follower must understand it to validate.
@ -264,7 +264,7 @@ factory and grepping `consensus/`; recorded in `RUN-A-NODE.md`, "Which Besu", an
over the block hash, which commits to it. Section 5 states what this means for a client that DOES
validate base fees.
### 2.6 Consensus messages under post-quantum seal (emission live since 2026-09-02; enforcement not yet armed)
### 2.6 Consensus messages under post-quantum seal (emission live since 2026-09-02; enforcement armed by height on 2026-09-05)
Since 28-31 August 2026 every one of the four QBFT message types (PROPOSAL, PREPARE, COMMIT,
ROUND-CHANGE) can carry a Falcon-512 seal of its author, and since 2 September 2026 all nine
@ -273,9 +273,15 @@ validators of chain 2800 EMIT such seals on every message they send (source:
`ProposalPayload.java`, `PreparePayload.java`, `CommitPayload.java`, `RoundChangePayload.java`;
fleet configuration measured 2026-09-02). This section specifies the wire format, the signed
pre-images, the emission gates and the enforcement rules exactly as the shipped client implements
them. **Enforcement is a per-node height switch and is NOT active on chain 2800 at publication:**
a message without a seal, or with an invalid seal, still counts as a vote everywhere on 2800. On
the public testnet 28001 all four enforcements are active (source: `testnet-public/CITESTE-MA.md`).
them. **Enforcement is a per-node height switch.** On chain 2800 the four heights were set on all
nine validators on 2026-09-05 [MEASURED 2026-09-05, `docker inspect` of every validator]:
`aere.pq.commitPq.forkBlock=17250000`, `aere.pq.proposalPq.forkBlock=17400000`,
`aere.pq.roundChangePq.forkBlock=17550000`, `aere.pq.preparePq.forkBlock=17700000`. Below each
height a message of that type without a seal, or with an invalid seal, still counts as a vote; from
it, it does not. Emission heights on 2800: `proposalPq.attachBlock=16620000`,
`roundChangePq.attachBlock=16660000`, `preparePq.attachBlock=16690000` (COMMIT seals are emitted
from the anchor). On the public testnet 28001 all four enforcements have been active since
2 September 2026 (source: `testnet-public/CITESTE-MA.md`).
**Wire format.** Each payload keeps its upstream RLP list and appends, as its LAST element, an
optional two-element list `[validatorIndex, signature]` (`validatorIndex` an integer index into the
@ -285,8 +291,10 @@ and `readFrom` refuses any encoding that does not reproduce the received bytes,
a seal and the same message without one never share a hash (source: `PreparePayload.writeTo`,
comment "This method DEFINES the canonical encoding"). A COMMIT may additionally carry a list of
non-Falcon "extra" seals for the hybrid certificate (`[schemeWireId, index, signature]`), REQUIRED
to be accompanied by a Falcon seal; no extra seals exist on the fleet today (source:
`CommitPayload.java`, constructor javadoc "empty for every commit on the fleet today").
to be accompanied by a Falcon seal. Since block 17,047,568 (`aere.pq.hybrid.attachBlock`) the
COMMIT of an anchor's PARENT carries one SLH-DSA-SHA2-128s extra seal per validator; other commits
carry none, because that seal costs seconds and is used only in the next anchor's certificate
[MEASURED 2026-09-05] (source: `CommitPayload.java`; `HybridSealProducer.java`).
**Signed pre-images: one domain per message type.** Every seal signs
`keccak256(RLP[DOMAIN, chainId, height, ...])` with an ASCII domain string that names the message
@ -364,9 +372,19 @@ section 3.1.
### 3.1 The claim, stated exactly
From block 13,014,000, every 32nd header of chain 2800 (an "anchor block") carries a certificate
of validator Falcon-512 seals over its parent, and the anchor block's keccak block hash covers a
32-byte commitment to that certificate. The enforced minimum number of valid seals is a height
From block 13,014,000, every 32nd header of chain 2800 (an "anchor block"), and from block
17,225,968 every 128th (`aere.pq.anchorIntervalSchedule=17225968:128`, set on every node on
2026-09-05), carries a certificate of validator seals over its parent, and the anchor block's keccak
block hash covers a 32-byte commitment to that certificate. Until block 17,047,600 the certificate is
v1, a list of `[index, falconSignature]` pairs under the domain `AERE-PQ-ANCHOR-1`; from block
17,047,600 (`aere.pq.anchorV2Block`) it is v2, `RLP[2, [[scheme, index, signature], ...]]` under the
domain `AERE-PQ-ANCHOR-2`, digest `keccak256(RLP[domain, chainId, parentNumber, parentHash,
BYTES(rlp(certificate))])`, with scheme 1 = Falcon-512 and 2 = SLH-DSA-SHA2-128s; the scheme
schedule `aere.pq.schemeSchedule=17047568:falcon-512+slh-dsa-sha2-128s` names which schemes are
required, the minimum applies to each scheme separately, and every SLH-DSA seal must sit at an index
that also carries a valid Falcon seal [MEASURED 2026-09-05, 400 consecutive anchors] (source:
`anchor/consensus/common/.../PqAnchorV2.java`, `PqSchemeSchedule.java`; the public verifier
`tools/verify-anchor.mjs`). The enforced minimum number of valid seals is a height
schedule: 0 from 13,014,000 (warm-up), 3 from 13,034,000 (f+1 of nine; enforced on the fleet since
2026-08-14), and **6 of 9 from block 14,961,456** (a full 2f+1 quorum, the same count QBFT itself
requires) (source: `anchor/README.md`, "Scope boundary"; `RUN-A-NODE.md`, the anchor section).
@ -413,6 +431,10 @@ passes"):
-Daere.pq.chainId=2800
-Daere.pq.anchorBlock=13014000
-Daere.pq.anchorInterval=32
-Daere.pq.anchorIntervalSchedule=17225968:128
-Daere.pq.anchorV2Block=17047600
-Daere.pq.hybrid.attachBlock=17047568
-Daere.pq.schemeSchedule=17047568:falcon-512+slh-dsa-sha2-128s
-Daere.pq.anchor.maxSeals=9
-Daere.pq.anchorMinSeals=13014000:0,13034000:3,14961456:6
-Daere.pq.anchor.minSealsCeiling=1
@ -437,6 +459,12 @@ Semantics of each parameter (source: `PqAnchorConfig.java`):
- `aere.pq.anchorInterval` (32): anchor heights are counted FROM H:
`(n - 13014000) % 32 == 0`, equivalently `n mod 32 == 16`. H itself is an anchor height. Between
anchor heights the rules judge nothing.
- `aere.pq.anchorIntervalSchedule` (`h:interval,...`): from `h` the interval changes; `h` must itself
be on the previous grid and the new interval a multiple of the old one, so every new anchor height is
also an old one. On 2800: `17225968:128`, so from that block `(n - 13014000) % 128 == 0`. A node
without the schedule keeps judging the old grid and rejects the first old-grid height that carries
no certificate; every node that judges anchors must carry the same schedule [MEASURED 2026-09-05 on
the public testnet, schedule `312000:128`].
- `aere.pq.anchorMinSeals`: `height:K` steps, floor semantics via `floorEntry(height)`; a step must
exist exactly at H; steps below H refuse.
- `aere.pq.anchor.minSealsCeiling`: effective K is `min(scheduled, ceiling)`. The ceiling can only

View File

@ -5,20 +5,22 @@ computed them, and the same 276 files once produced two different aggregates her
329e4e2f93143cc8af092f0a951fa23619c12c4ec1793b053654f8e26e088da0 app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java
3f289d74bb1878280a99742320d7a27c19146a2f19cdf6a0857f2aad4361ad5e app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java
51764581814253cf76fa631e2d6557581a7bf9ae585a701009e37b80345f8867 app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java
05765f25da67f9b14d490121e73536a41c107edcc841c7303afaae93061ebf39 app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java
129493150c0b13e8020bdf50c0c1ae07268d4f73a69a821b303bf4894451fa5e app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java
728c29c299edf6011f2c2798cbe930a043112e0e06b924e7edb54284ca44a758 config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java
a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/common/build.gradle
657b2c652c7995976acab3feca7fec5e15acc66cdfe1d5cc23ced6813db17a5f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java
e32a03de9f1452bd7444a33b084516ae3399d7a5ae7430088714f5656d3a22ea consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
68d635954ef33448a661256f50642eeeff2f7bb5874a54ffc2e64193c83fe391 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelay.java
8fa317b1ba1d887f0623610f8721d728d954cc800d3b391729ed06f64c8141a6 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java
2e84bb48611d3da7c46bcd4018ae746cbf785dfcb16f495674a0f6d542e71e8e consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
adcc6c05e998f279fc6b863ead155f78b936a969985de40e1dda5bab641e73cf consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BlockTimer.java
3ab425b5b5f7d7c2199065a3905623d5c1c4c5922d892c6c4275415b0c69a2b4 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
db1e80115ce59c8281fbf046cb37e153a49fe017c1675afd200e8125d3f6b538 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java
789db234a90f118cdf881653ee2bb7ad4a229a685315b04822147ffffe6a3c13 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
e8c8111a343cc993b950ac4e5b3992f16b8ee3d34c8827f00dd80d017884335b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java
03043d2360c35de3b6f27d029b7ee83cb6c964f4b827a504799607b8bfe1cdcc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java
65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
be99cd76f4b80a0c83e0a18801249c7972a9e93c54bbad6158d1155753a7ad6a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java
681d5ca2ea929d81f92664341d2651160d50621efb5194808627fb61834c292b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
ecd0e865aa48e461618048c13c46d9b54095dfa2e14180750f49b90faf49302a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
852113d420f2c6b82945423a18591c3b2bd1018febe1a52e8b4e42a996c85114 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
1c3d90324c6f5116194bae0539680fdc1f528ba4c893c5e2b44a592aea6b3abd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
c6285ca43331781e1d350a07d58256d02b8b3a74b96b6469c0dca33ecfb6c8cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java
f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
81ddbf2b40f79bf0e382c507235f07ad74937ffd455e387c265880bdcf4e2b5c consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java
@ -28,22 +30,44 @@ e16b2c654fe21893c7972dac71f3ffafd1e4e8efbecdb6f95051820e36fbd48e consensus/comm
f67a205036550f7d0f1bfed2abe7b89abd73e80614adff8d696b33c580269a6f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java
3fbd850e00e487c40c61310d894d6e1f765e9098e5c55438f9b06ab8fb92d45b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java
e85f4be696700184f9700cc531b4f3df313e9fb8cff8e2bc39d24241a769addb consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java
d2e8b34f29d0dd83a66b78b53ce430404834cdf9ba334abeb72481d5d681aeb9 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java
e8f7a8c4d8f1626d8efd167e5096de14ca17d40af58577737c5ef6a769dcc4d5 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java
c0c93a096f4eceab6bf92cc3618ce7cee1e54d2d20b6ac781850a390ec6e514f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java
73abbaddcec1533a7a758f1c4a26ae68a83ddbb96fd98ad147e74d7386bbbf3f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java
a235b3ec8c69665c525d7c74a084d589904879f5cd33cb87e1d1f38eabba8a15 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java
addba40c0d931a3ecfa3b2f0179311dacaa604884e3ebb1d79f958bd913f72e2 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java
285f6a7c1188a387fb1ac21f5ab809595ef48d936b40aced94047525e6def0a6 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java
352ffd303d2fcdc0d09ab943cebb2cd22b9c6329e129eb522f51fed8357f877d consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java
1ef188292858db2a6b70c074a2477223bf96e2762a3711c8a993404eb2db2cfa consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java
3b57840dc103063b3797697feb78b90af93ca123587e9278cfb43b84edbd995a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java
444961503d515cb9f045729577e190941e59baa4bd90438a2da05f41fed7750a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java
b6c9ecbf3cd2ee73111984cd04a89c32ee56a1cc699a197b65ef7c10001b8cbc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java
5c8861eba1ea697d8deb88139d92c6ea6636b29d76c881b13e7f6c841ba2324f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java
15ee94346cd803530cf9362b74c645aad0986c6d26a5386f9f38ee4e92a66b7b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java
baa7f138046c501c5baecae3615d54ed0bbd6fea47d7f84a3d71eaa5698d182d consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/ADRS.java
7c464b927fcb39ab017c543ca42bcd076b1dc69573c7ddefda1e685ebf9eb9ba consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/Fors.java
ddb17d7c6e1b762a9e8bcc924a17d42218cb5ec16528d80170bf2ff07e40aba2 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/HT.java
daf4c242682df6365aa7459990a9ba7b5f97e9a06cfff95c96afe1eadc061647 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/IndexedDigest.java
ddd60bf21ee7207b5b7d72fda5cd8bd9f9515536744379aa7d1bb7e3cdc9abdb consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/JdkDigest.java
108a2d252eea02e005889942966d2dcf845e1d01fc5e7bfdf85b58e1ac0697cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/NodeEntry.java
91ff4c4bc681bde06e72188e28177170645ca72cb72a08b08c3cf2283376a933 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/PK.java
ef1fcba8ac1d98f6bcb44616b33551cc37c21fa4fd0e5adc967bd4960c6bc184 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG.java
9b9b7b171e816d6b142978ada7ea4b4491dbad025d26f431d6a6fc03130b047a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_FORS.java
21371b535b9818ba0bb4b094837b11b00dc3132187a7b67f1b11196ab386f1f5 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_XMSS.java
07ed38417f5f4a4c56631c82651e70708ab47b8b6f017096ea281f0436180671 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SK.java
8107ac08eabfee9016ecea9cdb53b78409f322c49706224def9c05501b297985 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngine.java
efa6edc2f6cdf5e67074ef793fb15fff1bf41f5030476b7f8de991ada54b15eb consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngineProvider.java
fa89107a6ff29b8d55d66949dd810cad36851a8ac847a0f7f33a843551f0e359 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyGenerationParameters.java
48e4e7448b8076376bcd5720e20dd36d07b9d7a953fdf992691d54fa20f1709d consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyPairGenerator.java
14411ba911129fb56f95c62a26aa00de8b2fa7de5994563ec10d89ada95cc697 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyParameters.java
c72f197a0f8967aec5e08d1c4bb30030b9dc16c27711567ca40ad0cbe845c989 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAParameters.java
3c39912f4a7f1d56ffbaf1b249ecb6726cbf98180e93c6a924fe3318552caaf0 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPrivateKeyParameters.java
3a18d7af3c85ab6a9239ff52be7524d141ed7e9e6cf84d34e8e4957434596b4c consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPublicKeyParameters.java
636399666ed9490b29a4cb85d2b3cddf2ef4e8f8fa9a8ecf2315a8abf3e2e6de consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSASigner.java
bd6833075646ffa2f4fca2761e3d68ccbc37f7c5bb209ae2e40f8cfd469dce14 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/WotsPlus.java
6ace00e18914a1558563e689b7427654b5e6226a31447bef1d0416d993f78840 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java
cf0dcc008ce2d040590329088abdcbfc0829bf529445422deee8022d4856e08a consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelayTest.java
acd5c1e7f49dbbf444c19c91386bbd03b9298d952d2d13721793670103e71ee9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java
c574ec22e642ac464b1728d27e4bb26202448a45c044f08935cc0a0efa0297d9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java
0eb20506f851c1510bcaf659db8c5b0d384cdc60ed06192a03a665f0ee2b3912 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java
186c4c92199400783ee3424231818dd440b0da41aeb3d8bd643cd13759998250 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java
834861639fd119c1653e6a3977ffbe4c2d4dce63a5741f1fe5a087643b86ed74 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java
220fceda0f5292054e322bb7df2d5258905a8a7b6f9febd802789d3946a0c01c consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java
2920f18dae0eb7ccead43d80202580b116e28c9fae5bb75d54917ddb99b9c2e9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java
6e2ae09f62765d6e558fdf56a124c890681c2b2c3d410bf8aa497820cd76c3a5 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java
7470d72271dd4f9de96094b3d1c6dc4b5b5ea62e46a058d4e154e3742d3ccef1 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java
e2487ff508ffb51bb61ca19531f4c4e68c5981611fd9fec5c92a2965f0bb8f47 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java
@ -53,6 +77,7 @@ fe84c239e3504f55e8d950e53b844641cc1dd7a7c864dd8f58db3d2f50c12b9d consensus/comm
23daeb4888c8de8337518a27ef60e42573ebafaf340075e8ce9236f0d8d580f6 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java
09c41c77408acc4711e4c46175485bf41babff0d0a8ec7481670fddfd28967b0 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java
6ff9586cf0d544590585ad27227f7775f1dd86c61ccdd475321180d4e53f47d2 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java
39656762e621e76b33ced524314e9692cfa875d7e8db8dbb22aaa6b7f9ad5c14 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2ProducerTest.java
72e58681267664a864dba5a371ffbc6f296b524c2dee248893b17c12da113f00 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java
49c95a24ce4890fe79b9167c196ba8da460e03bdddfee5652944e130fed8ee78 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java
74e2629acdcf9242c32340679914fa40f50d0a9479c83018b9b7a0733397a843 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java
@ -66,7 +91,7 @@ f260d284f936307ac4c31142cbea0359ded1197880b8a960ac27dd7beb5c3b53 consensus/comm
97281811ae1fe6fae8001d0d64e11d4779be153547ab24c779a54aba3e211c88 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java
7ec10b4fa5ac09e4d980f5d180c75eecc892d95c2f81d97c6140e64ada34654d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java
36cd369e8386cac85501f7d8a56f9604ec39e8bd89a8d754cabe85e4fb6b8e9a consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java
44b0010772765eacb8fb720cc0570b0a8f681a3a329060271e67b7c518ddb25b consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java
e73a075b2acfce7a6d2de63130ed55a3d8f422bdc72c51325a010852064d406d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java
e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java
9a371ec367cda846640c6da18c8bf7d921a6882cfc024dc0c86d4f965cae0c63 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
@ -74,7 +99,9 @@ e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/comm
e355b147fdda48c14cb6dc09b1bc1e7a7ccae3f9b709af8f8483f896086dbe4d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java
45a881d122a7ffa5ff4d2db38a2750e3918055593c51e1b1241342905a30565d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java
d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java
22ade1aa2d8254d1f7ff02bbbcb4d02155546a52fca3421b61eea1129b73ee48 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
184e3cc6f2f9beb1bd972b077ab2ced456264a55296f810ba5ed2dc976195392 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaCrossVectorTest.java
ae37d3525697bf84defd796bb8813193c78f81e7fa24a72f8f048285d52399e2 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaFastEngineTest.java
6b6ecfcfb9b25bb009cb5d4df74648c729abf62626e5034af126771825b7418e consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
597d96d64c9394c79d5084a1ab5df884b2266efe315c0f2d563bfc17b7421480 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
d365bad33f3aee4597c630071ca1f727ddd8bb6ab6515fccbce0a07842037adf consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
@ -82,12 +109,13 @@ d365bad33f3aee4597c630071ca1f727ddd8bb6ab6515fccbce0a07842037adf consensus/qbft
428e11639fb24ae4b04972457714445a5cd59d7946135a8b7362123461fa690f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java
2e133e92dabb6b7add09820f9239050bd8d3b9ad63c16ed7f6e725a9694f2232 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java
51eddf3cb8e37489abd35e6f4c36294a13ec76337bbbbfd54c07584085dceaac consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
252f9a86ca17cc6362780af264dbc4da4d842191467427a809ff1e4baa7640af consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
609890459ba6be78d0b91c04e80927231e8d61a0af822c4049ff0972530ab7ea consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
7fef8f228638187615de0c7c92f1ae41a83a9c2997e64ef527f125a204e40204 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
9c7e2698016f8cf19d3b6d78c6256ad5e8eabec06d9a877eee4e8c2dde423839 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
00c8204e4b44acd469973d6bbc07992382fdd2d692f079a55de76f944a7432f8 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
601b0cccf32f8ebe327c7581e0e9b3f9f67de49d9fb646326d6b49ef2138c641 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
7f9fb13a7bbe3015bbef6329f750f2b35fb3744895cd3e6599af90450fab6a73 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java
ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java
01ed4a44aa58da124efb6f4fcf479f9511c8f522f40ccf0be5fff3d0bcb38f34 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java
e533a19b256f7d9a64980af283bd8ce148bc8ae3c297a3cd92908c9d1a70981e consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidator.java
bebf53cbc7f3a9ca2807bd980d313f8131e96f79259ba41e80a598d0327b1a0d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java
5ab52433f770537e838b15e23abfc7b24eaa733ea26577451d5e32226d617879 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java
742611d52091a6c138cdff60c5f7f0bfb2fc33e567f520f3e86878fc086e77d9 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java
afb487f8c1dd87e323fdc1153ecfdf8ec6cb5a10e0726783d4214c07a3fa9a95 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java
@ -100,11 +128,15 @@ e2107737579c16b73cbd2c9b50e90abcd6c910309286fb862cef780555ca43a5 consensus/qbft
fe25255de4b5fa6d86412fa4c48218180f549f0a4ffea5b5cc0a7d19a5be4cd9 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java
a994d5638940ea90f37ed6f3ed5eebc24781707f2f4555c7a079d1251ddbf893 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java
df5d7d2119cf0864a8c2dce50f4f69bd8b64804cfda7f7366c013b304af0a6e7 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java
97ac1e46ab37238a2b6232eccdd4636ac6fa928c8e3d4f8a473c083c5d5c3b39 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqHybridExtrasNeverThrowTest.java
0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java
127de5fbe8014768a657f31bc77d7e66557273febdf3412a5536e8628fd70c5c consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqSealsWithoutProposalTest.java
3f945a0a73288e42c82026ead5d118ab6b7a8200aacc881855aff442d0954c1c consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorAnchorFormTest.java
8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java
3374a7ee317a807528c79b8218dbb34b308de8b7a908cb6680b83356641fcade consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidatorAnchorFormPlumbingTest.java
401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java
c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java
5e0bb0ecc77ffb06f232e1aa81cca6870e5455af7c2846ae54d5f477f1bbb88b consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java
61f6f0ca66cabbad7e57d7eccf86a6d4f5434f0769726db24efec38c9503583d consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java
bca46ca91803c89eb18d670c740edb4e47ef0bca53a9b4b82ce0c8446e0d6c0e consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqMessageAgilityTest.java
5a47d247d7bcda77b57f5c906c3cee1af826785416012fafe9cfa49d63671388 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java
1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java
@ -116,14 +148,14 @@ dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft
83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java
8f6abb4297df964e934cfeabe51f365478e917b73cd6909f621c9e201e280aa5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java
79dea5e85b968af696bf57d51d0de175a4c025537200402e3594a4a8290d1a29 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
47fbdd8639c464bf19bac3a0ba540af4d99f79e9df02c2ad586a19a2aebd037d consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
42b86cd888efe1aa00f9c9477566e353649c609c13a042e514185b57a6674a33 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
103d46eecfc11c97fb59a1e0237a6b5b6d384a43ec45f5028bd225b8bf8e3f6c consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
cfb0aad408ab620f28b09914ece7d8d3d09f9e6bbb503070e28d9ef39c023dee consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java
cbec6f4e280250ddefe5c79c453b59a07be6435e6f88d5614e92168c855e4693 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRule.java
e4e75c28ff15d058176145b1ae32606bf91b22112d1668a718f1a6ae4add0e71 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java
ab0fcd8722dcb76560f0ef8fda8af9c2b8b6ec9b468326aaf616c853526f7f81 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java
81a46071e77eb72aac107e6afca9e50612a81b66c5f17999815652eeda005248 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java
05ee2e97644c9a79ee082a5030b6e2a6745933d26cb8238a6339e6006c47d7e3 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java
63313f944fcda0db45c40d4df840a465e6147cc85997b8ca832f0cfefea97987 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java
bfea357d0f539ae7d2d6fabf2ea343e0bce3d3faf2cea57c6d5e97c75552371b consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java
2add2a7733668e11b50ddd64b05bb37db26a18284a636bbe720c2cb1f6aa28ed consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java
8ac99e94c89f61f0281199cf369e282fc4f9fb1a3414a1650766e1d7548e76f5 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java
52b56f157500ae3527b7e55c51786915bb5d6980065668f1e7b8e297bf7125ef consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java
@ -134,6 +166,7 @@ e2df575ee4d6ab5bd961b0886ece3d3c392a50193f1d3256a438a72da9e20d10 consensus/qbft
77d16fd35ca5607e9702f39cfd9a24a7e8523f631a73e82138763e6d5b718e7b consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java
a81da71dd34dd111a0da43c22d22345dcc595c084a2c2d36aec1f45d8b762f80 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java
535cca4f1083c8a25bd29877e988b99704666853c1fe8b0ba32d71efe4e15f37 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java
612d5c7578a918441265a6ad99bf05e9c5e94eec743c375ffab03d1b19e2f797 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java
321cb7e6923f77e078b523316650ac49136cf8d4df506b9bb235a32795c69611 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java
484bc74ff3d1e52d25631264d83af70c2b695e973c7360b0f3db471ab5c6613a consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java
49c3fd97e7985966531928af173b6f38ac48ff9dc8b5be1106e5b7bfb9c7b2b6 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java

View File

@ -26,6 +26,22 @@ should not be confused:
startup guard refuses any threshold above N - f = 7, the highest count reachable with the fault
budget down. Blocks between anchors carry no Falcon seals and are finalized by ECDSA alone, so
the post-quantum guarantee is a checkpoint about every 16 seconds, not a per-block quorum.
3. **The hybrid certificate.** Since block 17,047,600 (2026-09-04) the certificate is the v2, scheme-tagged
form `RLP[2, [[scheme, index, signature], ...]]` and carries Falcon-512 (lattice) AND SLH-DSA-SHA2-128s
(hash-based) seals; the enforced minimum applies to EACH scheme on its own, at least six of nine valid
seals of each, and every SLH-DSA seal must sit at an index that also carries a valid Falcon seal. Properties:
`aere.pq.anchorV2Block=17047600`, `aere.pq.hybrid.attachBlock=17047568`,
`aere.pq.schemeSchedule=17047568:falcon-512+slh-dsa-sha2-128s`, `aere.pq.hybridRegistry` (the epoch-2 key
manifest, also published under `/pq/manifests/`). The fleet schedule carries one dated step,
`17102384:0,17102416:6`: on 2026-09-04 a run of validator restarts emptied every node's seal store, the
chain stalled at anchor 17,102,384 for eleven minutes, and it was released by lowering the minimum to
zero at exactly that height and restoring six at the next anchor. The step stays in the schedule because
every node must agree on that history.
4. **The anchor interval.** `aere.pq.anchorIntervalSchedule=17225968:128`, set on all nine validators and
on every reading node on 2026-09-05: from block 17,225,968 an anchor falls on every 128th block instead
of every 32nd. Each hybrid certificate adds about 53 KB to its header; quartering the frequency divides
that cost by four without touching the rule, the thresholds or the schemes. Until that height the
interval is 32, and the checkpoint above becomes one about every 64 seconds after it.
Correction 2026-08-19: the fleet was also configured on 2026-08-15 with `aere.falcon.forkBlock`
at 14,050,000, intended as a per-block blocking Falcon quorum. In the shipped code that rule
@ -35,12 +51,17 @@ inert, and the chain itself confirms it: blocks between anchors carry no Falcon
per-block quorum rule would have rejected. The earlier statement in this repository that from
14,050,000 every block required a 2f+1 Falcon quorum to finalize is withdrawn.
"Hybrid" is the honest word only in the checkpoint sense above, and "post-quantum consensus"
without that qualification overstates: block proposal, validator identity and message transport
are classical elliptic-curve cryptography, and a cryptographically relevant quantum computer would
break those exactly as on any other ECDSA chain. What it cannot do is rewrite history past the
last anchor without also forging Falcon-512 signatures. None of this has been audited by a third
party.
"Hybrid" is the honest word in two senses and no more: the checkpoint sense above and, since
2026-09-04, the two-scheme certificate. "Post-quantum consensus" without qualification still
overstates: per-block finality and validator identity are classical elliptic-curve cryptography. The
four QBFT message types carry a post-quantum seal of their author on chain 2800 since blocks
16,620,000 (PROPOSAL), 16,660,000 (ROUND-CHANGE) and 16,690,000 (PREPARE), COMMIT seals since the
anchor itself; and a message without a valid seal is REFUSED from blocks 17,250,000 (COMMIT),
17,400,000 (PROPOSAL), 17,550,000 (ROUND-CHANGE) and 17,700,000 (PREPARE). Those four enforcement
heights were set on all nine validators on 2026-09-05 and take effect as the chain reaches them;
until each height passes, that message type is still accepted without a seal. What a quantum
adversary cannot do at any height is rewrite history past the last anchor without also forging
Falcon-512 and SLH-DSA-SHA2-128s signatures. None of this has been audited by a third party.
The threat it addresses is not "harvest now, decrypt later". A signature is public; there is nothing
to harvest. The threat is **retroactive rewriting**: validator keys recovered later can be used to
@ -145,13 +166,16 @@ This matters more than the file list, so it is stated before it.
| Anchor certificate under the block hash | yes | **yes**, since block 13,014,000 |
| Enforced minimum seals at an anchor height | yes | **yes**, 6 of 9 since 14,961,456 |
| Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block |
| Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property |
| Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property |
| Post-quantum seal on the PROPOSAL, emission | yes | **no** — no node sets the property |
| Post-quantum seal on the PROPOSAL, enforcement | yes | **no** — no node sets the property |
| Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated |
| Post-quantum seal on PREPARE, emission | yes | **yes**, since block 16,690,000 |
| Post-quantum seal on PREPARE, enforcement | yes | **armed for block 17,700,000** (set 2026-09-05) |
| Post-quantum seal on the PROPOSAL, emission | yes | **yes**, since block 16,620,000 |
| Post-quantum seal on the PROPOSAL, enforcement | yes | **armed for block 17,400,000** (set 2026-09-05) |
| Post-quantum seal on ROUND-CHANGE, emission / enforcement | yes | **yes**, since block 16,660,000 / **armed for block 17,550,000** |
| Post-quantum seal on COMMIT, emission / enforcement | yes | **yes**, since the anchor / **armed for block 17,250,000** |
| Hybrid Falcon + SLH-DSA certificate (v2) | yes | **yes**, since block 17,047,600 (2026-09-04) |
| Anchor interval 128 (`aere.pq.anchorIntervalSchedule`) | yes | **armed for block 17,225,968** (set 2026-09-05) |
Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them
Everything not yet in force is **disarmed by absence or by height, never by a flag**: the properties that arm them
are unset, and unset means never. Each refuses loudly on a value it cannot parse rather than booting
a node that believes itself armed — a node that disarms itself because of a mistyped character looks
exactly like a correctly configured one, right up to the day it matters. The tests for that
@ -159,8 +183,9 @@ behaviour are in this directory and they are the ones to read if you doubt the c
The PREPARE layer is newer than the anchor and stronger where it applies: an armed node that refuses
unsealed PREPAREs never reaches the prepared state, so it never sends COMMIT at all. That also means
it has no safety net during an activation, which is why it is not armed anywhere and why its
activation height is a decision that has not been taken.
it has no safety net during an activation, which is why it was armed LAST and highest: its
enforcement height on chain 2800 is 17,700,000, set on 2026-09-05 after the COMMIT, PROPOSAL and
ROUND-CHANGE heights, so that the three other layers are in force before it.
The PROPOSAL layer (added 2026-08-30) closes the remaining hot-path gap, and its claim is stated
precisely because the imprecise version oversells: an adversary who breaks ECDSA cannot finalize
@ -168,9 +193,9 @@ anything while the PREPARE layer is armed, since "prepared" needs a full quorum
forged proposals could still do is OPEN rounds and waste them. With proposal enforcement armed, a
proposal without a valid Falcon seal from its own proposer does not open a round. The seal signs
its own domain over (chainId, height, round, digest), so an honest proposal seal cannot be replayed
as a vote nor a vote seal as a proposal - both directions are tested. What remains classical: the
ROUND-CHANGE message itself (its embedded justifications are already coupled to PREPARE
enforcement) and node-level devp2p authentication.
as a vote nor a vote seal as a proposal - both directions are tested. The ROUND-CHANGE message
carries its own seal since 2026-08-31 (its embedded justifications are coupled to PREPARE
enforcement). What remains classical: node-level devp2p authentication and validator identity.
### The files
@ -194,9 +219,16 @@ already take the dependency in exactly this versionless form. So the line adds a
and no new artifact, and it does not move any version. That is the entire build change, and it is
called out here rather than left to be found in the diff.
No cryptography is implemented in this overlay. Falcon signature verification calls Bouncy Castle's
implementation; what is ours is the framing, the registry that maps a validator to a key, the digest,
and the validation rules.
Falcon signature verification calls Bouncy Castle's implementation. SLH-DSA-SHA2-128s signing and
verification use the `slhdsa/` package in `consensus/common`: Bouncy Castle 1.80's own SLH-DSA
implementation, copied into the tree with one change, the SHA-256 and SHA-512 digests come from the
JDK (`MessageDigest`, hardware SHA extensions) instead of Bouncy Castle's pure-Java digests, because
the pure-Java path made one anchor signature cost 1.4 to 2.7 seconds on the consensus thread
(measured 2026-09-04). Signatures are byte-identical to the original in deterministic mode, which
`SlhDsaFastEngineTest` pins with cross-verification in both directions. The package is regenerated
from the published Bouncy Castle sources by a script, not edited by hand, and carries the Bouncy
Castle licence next to it (`slhdsa/LICENSE-BouncyCastle.txt`). What is ours is the framing, the
registry that maps a validator to its keys, the digest, the scheme schedule and the validation rules.
---
@ -225,14 +257,15 @@ and the validation rules.
upgraded every node.
- **Nothing here demonstrates what is configured on any live network.** These files show what the
code does when armed. They are not evidence about any running fleet, and should not be read as any.
- **The PREPARE layer has not run on a live network.** It has been exercised on a test network,
including a mixed run against a second, independent client implementation, and it has not been
armed on chain 2800 or anywhere else that carries value. Test-network evidence is evidence about a
test network.
- **The hybrid Falcon + SLH-DSA certificate has never been signed with a real key.** The scheme
layer is here and a second algorithm passes through the same consensus code untouched, which is
what the tests measure. Generating hybrid validator keys is a separate decision that has not been
taken, so no hybrid certificate exists on any chain.
- **The message-layer ENFORCEMENT has not yet refused anything on chain 2800.** Emission runs on the
live fleet; enforcement is armed by height (see the table) and, at the time of writing, none of the
four heights has passed. The refusal path has been exercised on the public testnet 28001, including
against a second, independent client implementation; test-network evidence is evidence about a test
network.
- **The hybrid certificate is live but young.** Hybrid certificates have been produced on chain 2800
since 2026-09-04 with real validator keys, and the public verifier reproduces them from the
internet. What is not shown here is any third-party review of the SLH-DSA engine copy or of the
scheme layer.
## One claim we retracted, on purpose
@ -275,4 +308,6 @@ each seal costs bytes in every header forever.
## Licence
Apache 2.0, matching upstream Hyperledger Besu. See `../LICENSE` and `../NOTICE`. Files that modify
upstream carry the change notice required by section 4(b); files that are new are ours.
upstream carry the change notice required by section 4(b); files that are new are ours. The `slhdsa/`
package in `consensus/common` is derived from Bouncy Castle and stays under the Bouncy Castle licence
(MIT-style); its notice is kept next to the code in `LICENSE-BouncyCastle.txt`.

View File

@ -41,6 +41,7 @@ import org.hyperledger.besu.consensus.common.bft.BlockTimer;
import org.hyperledger.besu.consensus.common.bft.EthSynchronizerUpdater;
import org.hyperledger.besu.consensus.common.bft.EventMultiplexer;
import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
import org.hyperledger.besu.consensus.common.bft.HybridSealSupport;
import org.hyperledger.besu.consensus.common.bft.MessageTracker;
import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
import org.hyperledger.besu.consensus.common.bft.PqAnchorSyncModeGuard;
@ -235,6 +236,14 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
.orElse(false),
dataDirectory);
// D-334 (2026-09-03): the HYBRID configuration (scheme schedule, registry, SLH-DSA key) is
// loaded HERE, at startup, and not on first use. Measured on mainnet 2800: a registry the
// container user could not read passed every configuration gate (the file existed) and blew up
// inside the QBFT state machine at every anchor parent on three armed validators; with a fourth
// node restarting the chain stood 125 s. A defect in this configuration must refuse to START,
// exactly like the Falcon key above; QbftRound.hybridExtrasOrEmpty is the second net.
HybridSealSupport.instance();
// AERE GARDA-CHEIE-MASINA (2026-08-07): the Falcon key loaded must be THIS machine's own.
//
// Measured on seven test nodes with a single variable changed: node 0 was handed index 1's key.

View File

@ -0,0 +1,152 @@
/*
* Copyright contributors to Aere Network.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in
* compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is
* distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing permissions and limitations under the
* License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.common.bft;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* D-337 (2026-09-04): how long the proposer waits, ONLY at an anchor height, before its proposal
* timer fires.
*
* <p>WHY THIS EXISTS, measured on chain 2800. At an anchor height the proposer must carry K valid
* seals of EVERY scheme in the schedule, and the SLH-DSA seals arrive with the commits of the
* parent. With the hybrid certificate live, the proposal timer fires about half a second after the
* parent and the sixth SLH-DSA seal is not there yet, so the producer refuses (it will not write a
* certificate it cannot fill), round 0 expires, and the anchor costs the whole four-second
* round-change timeout: eleven of twelve anchors measured on 2026-09-04, parent-to-anchor 5.6 s
* instead of 0.8 s. Making the signature four times faster fixed the PARENT (3.3 s to 1 s) and did
* not fix this, because the race is against the proposal timer, not against the CPU.
*
* <p>WHAT IT IS NOT. This is local timing, not consensus. A node that does not set it proposes
* exactly as before; a proposal that arrives later is valid under the same rules (QBFT only
* requires the timestamp to be at least a block period after the parent). So it can be set node by
* node, with no activation height and no fleet agreement, and a wrong value costs liveness at
* anchors, never a fork.
*
* <p>Unset or zero means today's behaviour, exactly. The value is read once, validated at node
* start (from {@link BlockTimer}'s constructor), and refused if it is negative or above five
* seconds: a delay longer than the round-change timeout would trade one stall for another.
*/
public final class AereAnchorProposalDelay {
private static final Logger LOG = LoggerFactory.getLogger(AereAnchorProposalDelay.class);
/** System property carrying the delay in milliseconds. */
public static final String PROPERTY = "aere.pq.anchorProposalDelayMs";
/** Environment variable carrying the delay in milliseconds. */
public static final String ENV = "AERE_PQ_ANCHOR_PROPOSAL_DELAY_MS";
/** The largest delay that can be configured; above this a stall is traded for a stall. */
public static final long MAX_MILLIS = 5000L;
private static volatile Long cached;
private AereAnchorProposalDelay() {}
/**
* The configured delay in milliseconds, read once and memoised.
*
* @return the delay, zero when unset
* @throws IllegalArgumentException if the value is not a number in [0, {@link #MAX_MILLIS}]
*/
public static long configuredMillis() {
Long local = cached;
if (local == null) {
synchronized (AereAnchorProposalDelay.class) {
local = cached;
if (local == null) {
local = parse(readRaw());
if (local > 0) {
LOG.info(
"AERE PQ-ANCHOR: proposals at anchor heights wait an extra {} ms for the seals of"
+ " the parent (D-337). This is local timing, not consensus.",
local);
}
cached = local;
}
}
}
return local;
}
/**
* The delay to add to the proposal timer for a given height: the configured value at an anchor
* height, zero everywhere else.
*
* @param blockNumber the height whose proposal timer is being started
* @return milliseconds to add
*/
public static long millisFor(final long blockNumber) {
final long delay = configuredMillis();
if (delay <= 0) {
return 0L;
}
try {
return PqAnchorProducer.config().anchorAppliesAt(blockNumber) ? delay : 0L;
} catch (final RuntimeException e) {
// The anchor configuration is read at start and refuses there; if it somehow cannot be read
// here, the honest answer is "no extra delay", never an exception into the block timer.
LOG.warn("AERE PQ-ANCHOR: cannot tell whether {} is an anchor height: {}", blockNumber, e.getMessage());
return 0L;
}
}
/**
* Parse and validate a raw value.
*
* @param raw the text, possibly null or blank
* @return the delay in milliseconds
*/
static long parse(final String raw) {
if (raw == null || raw.isBlank()) {
return 0L;
}
final long value;
try {
value = Long.parseLong(raw.trim());
} catch (final NumberFormatException e) {
throw new IllegalArgumentException(
"AERE-PQC-ANCHOR-CONF-05: " + PROPERTY + "='" + raw + "' is not a number of milliseconds");
}
if (value < 0 || value > MAX_MILLIS) {
throw new IllegalArgumentException(
"AERE-PQC-ANCHOR-CONF-05: "
+ PROPERTY
+ "="
+ value
+ " is outside [0, "
+ MAX_MILLIS
+ "] ms. A delay longer than the round-change timeout trades one stall for another.");
}
return value;
}
private static String readRaw() {
final String property = System.getProperty(PROPERTY);
return property != null ? property : System.getenv(ENV);
}
/**
* Drop the memoised value. For tests only.
*/
static void forgetForTesting() {
synchronized (AereAnchorProposalDelay.class) {
cached = null;
}
}
}

View File

@ -113,7 +113,11 @@ public class BftBlockInterface implements BlockInterface {
prevExtraData.getVote(),
round,
prevExtraData.getValidators(),
prevExtraData.getFalconSeals());
prevExtraData.getFalconSeals(),
// AERE ANCHOR V2 (2026-09-03, D-328): the scheme-tagged certificate rides along too. Without
// this line every v2 header lost its certificate at the round substitution and the fleet
// refused its own first v2 anchor (testnet 28001, block 168032, 3 hours stalled).
prevExtraData.getHybridSeals());
final BlockHeaderBuilder headerBuilder = BlockHeaderBuilder.fromHeader(block.getHeader());
headerBuilder

View File

@ -28,6 +28,7 @@ import org.hyperledger.besu.ethereum.core.ParsedExtraData;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.apache.tuweni.bytes.Bytes;
@ -46,6 +47,8 @@ public class BftExtraData implements ParsedExtraData {
* committed seals or the block/header hash.
*/
private final Collection<FalconSeal> falconSeals;
/** AERE ANCHOR V2: the scheme-tagged certificate; empty on every header below the v2 height. */
private final List<SchemeSeal> hybridSeals;
/**
* Instantiates a new Bft extra data (no Falcon seals). Retained for callers, notably the IBFT
@ -83,7 +86,30 @@ public class BftExtraData implements ParsedExtraData {
final int round,
final Collection<Address> validators,
final Collection<FalconSeal> falconSeals) {
this(vanityData, seals, vote, round, validators, falconSeals, Collections.emptyList());
}
/**
* Full constructor.
*
* @param hybridSeals the scheme-tagged (v2) certificate, or empty; a header carries the v1 Falcon
* list OR the v2 certificate, never both
*/
public BftExtraData(
final Bytes vanityData,
final Collection<SECPSignature> seals,
final Optional<Vote> vote,
final int round,
final Collection<Address> validators,
final Collection<FalconSeal> falconSeals,
final List<SchemeSeal> hybridSeals) {
checkNotNull(vanityData);
checkNotNull(hybridSeals);
if (!hybridSeals.isEmpty() && falconSeals != null && !falconSeals.isEmpty()) {
throw new IllegalArgumentException(
"AERE ANCHOR V2: a header carries the v1 Falcon list OR the v2 certificate, not both");
}
this.hybridSeals = List.copyOf(hybridSeals);
checkNotNull(seals);
checkNotNull(validators);
checkNotNull(falconSeals);
@ -149,6 +175,15 @@ public class BftExtraData implements ParsedExtraData {
return falconSeals;
}
/**
* The scheme-tagged (v2) certificate.
*
* @return the v2 seals, canonical order; empty below the v2 height
*/
public List<SchemeSeal> getHybridSeals() {
return hybridSeals;
}
@Override
public String toString() {
return "BftExtraData{"
@ -164,6 +199,8 @@ public class BftExtraData implements ParsedExtraData {
+ round
+ ", falconSeals="
+ falconSeals
+ ", hybridSeals="
+ hybridSeals
+ '}';
}
}

View File

@ -0,0 +1,216 @@
/*
* Copyright ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.common.bft;
import org.hyperledger.besu.config.BftConfigOptions;
import org.hyperledger.besu.consensus.common.ForksSchedule;
import org.hyperledger.besu.consensus.common.bft.events.BlockTimerExpiry;
import java.time.Clock;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Class for starting and keeping organised block timers */
public class BlockTimer {
private static final Logger LOG = LoggerFactory.getLogger(BlockTimer.class);
private final ForksSchedule<? extends BftConfigOptions> forksSchedule;
private final BftExecutors bftExecutors;
private Optional<ScheduledFuture<?>> currentTimerTask;
private final BftEventQueue queue;
private final Clock clock;
private long blockPeriodSeconds;
private long emptyBlockPeriodSeconds;
/**
* Construct a BlockTimer with primed executor service ready to start timers
*
* @param queue The queue in which to put block expiry events
* @param forksSchedule Bft fork schedule that contains block period seconds
* @param bftExecutors Executor services that timers can be scheduled with
* @param clock System clock
*/
public BlockTimer(
final BftEventQueue queue,
final ForksSchedule<? extends BftConfigOptions> forksSchedule,
final BftExecutors bftExecutors,
final Clock clock) {
// D-334: o configuratie gresita se refuza la PORNIRE, nu la prima intrebuintare in masina de stari
AereAnchorProposalDelay.configuredMillis();
this.queue = queue;
this.forksSchedule = forksSchedule;
this.bftExecutors = bftExecutors;
this.currentTimerTask = Optional.empty();
this.clock = clock;
this.blockPeriodSeconds = 0;
this.emptyBlockPeriodSeconds = 0;
}
/** Cancels the current running round timer if there is one */
public synchronized void cancelTimer() {
currentTimerTask.ifPresent(t -> t.cancel(false));
currentTimerTask = Optional.empty();
}
/**
* Whether there is a timer currently running or not
*
* @return boolean of whether a timer is ticking or not
*/
public synchronized boolean isRunning() {
return currentTimerTask.map(t -> !t.isDone()).orElse(false);
}
/**
* Starts a timer for the supplied round cancelling any previously active block timer
*
* @param round The round identifier which this timer is tracking
* @param headerTimestamp The timestamp from the of the chain head header
*/
public synchronized void startTimer(
final ConsensusRoundIdentifier round, final Supplier<Long> headerTimestamp) {
cancelTimer();
final BftConfigOptions currentForkOptions =
forksSchedule.getFork(round.getSequenceNumber(), headerTimestamp.get()).getValue();
final long expiryTime;
int currentBlockPeriodSeconds = currentForkOptions.getBlockPeriodSeconds();
final int nextBlockPeriodSeconds =
forksSchedule
.getFork(round.getSequenceNumber(), headerTimestamp.get() + currentBlockPeriodSeconds)
.getValue()
.getBlockPeriodSeconds();
// If the block period seconds change between the current block and the next one we need to
// produce this block on the longer of the two values,
// otherwise block validation will fail (blocks produced too close together)
if (nextBlockPeriodSeconds > currentBlockPeriodSeconds) {
currentBlockPeriodSeconds = nextBlockPeriodSeconds;
}
// Experimental option for test scenarios only. Not for production use.
final long blockPeriodMilliseconds = currentForkOptions.getBlockPeriodMilliseconds();
if (blockPeriodMilliseconds > 0) {
// Experimental mode for setting < 1 second block periods e.g. for CI/CD pipelines
// running tests against Besu
expiryTime = clock.millis() + blockPeriodMilliseconds;
LOG.warn(
"Test-mode only xblockperiodmilliseconds has been set to {} millisecond blocks. Do not use in a production system.",
blockPeriodMilliseconds);
} else {
// absolute time when the timer is supposed to expire
final long minimumTimeBetweenBlocksMillis = currentBlockPeriodSeconds * 1000L;
expiryTime = headerTimestamp.get() * 1_000 + minimumTimeBetweenBlocksMillis;
}
final int emptyBlockPeriodSeconds = currentForkOptions.getEmptyBlockPeriodSeconds();
setBlockTimes(currentBlockPeriodSeconds, emptyBlockPeriodSeconds);
// AERE D-337 (2026-09-04): at an anchor height the proposer needs K seals of every scheme, and
// those ride on the commits of the parent. Measured on chain 2800, the timer fires before the
// sixth SLH-DSA seal arrives, the producer refuses to write a certificate it cannot fill, and
// the anchor pays the whole round-change timeout. The extra wait applies ONLY at anchor heights
// and is zero unless configured; see AereAnchorProposalDelay for why this is local timing and
// not consensus.
startTimer(round, expiryTime + AereAnchorProposalDelay.millisFor(round.getSequenceNumber()));
}
/**
* Checks if the empty block timer is expired
*
* @param headerTimestamp Function to get the chain head timestamp
* @param currentTimeInMillis The current time
* @return a boolean value
*/
public synchronized boolean checkEmptyBlockExpired(
final Supplier<Long> headerTimestamp, final long currentTimeInMillis) {
final long emptyBlockPeriodExpiryTime =
(headerTimestamp.get() + emptyBlockPeriodSeconds) * 1000;
if (currentTimeInMillis > emptyBlockPeriodExpiryTime) {
LOG.debug("Empty Block expired");
return true;
}
LOG.debug("Empty Block NOT expired");
return false;
}
/**
* Resets the empty block timer
*
* @param roundIdentifier The current round identifier
* @param headerTimestamp Function to get timestamp from the header of the chain head
* @param currentTimeInMillis The current time
*/
public void resetTimerForEmptyBlock(
final ConsensusRoundIdentifier roundIdentifier,
final Supplier<Long> headerTimestamp,
final long currentTimeInMillis) {
final long emptyBlockPeriodExpiryTime =
(headerTimestamp.get() + emptyBlockPeriodSeconds) * 1000;
final long nextBlockPeriodExpiryTime = currentTimeInMillis + blockPeriodSeconds * 1000;
startTimer(roundIdentifier, Math.min(emptyBlockPeriodExpiryTime, nextBlockPeriodExpiryTime));
}
private synchronized void startTimer(
final ConsensusRoundIdentifier round, final long expiryTime) {
cancelTimer();
final long now = clock.millis();
if (expiryTime > now) {
final long delay = expiryTime - now;
final Runnable newTimerRunnable = () -> queue.add(new BlockTimerExpiry(round));
final ScheduledFuture<?> newTimerTask =
bftExecutors.scheduleTask(newTimerRunnable, delay, TimeUnit.MILLISECONDS);
currentTimerTask = Optional.of(newTimerTask);
} else {
queue.add(new BlockTimerExpiry(round));
}
}
private synchronized void setBlockTimes(
final int blockPeriodSeconds, final int emptyBlockPeriodSeconds) {
this.blockPeriodSeconds = blockPeriodSeconds;
this.emptyBlockPeriodSeconds = emptyBlockPeriodSeconds;
}
/**
* Retrieves the Block Period Seconds
*
* @return the Block Period Seconds
*/
public synchronized long getBlockPeriodSeconds() {
return blockPeriodSeconds;
}
/**
* Retrieves the Empty Block Period Seconds
*
* @return the Empty Block Period Seconds
*/
public synchronized long getEmptyBlockPeriodSeconds() {
return emptyBlockPeriodSeconds;
}
}

View File

@ -127,7 +127,7 @@ public final class HybridSealSupport {
}
/** Drops the cached instance, for tests only. */
static void resetForTesting() {
public static void resetForTesting() {
instance = null;
}

View File

@ -185,7 +185,24 @@ public final class HybridSignerRegistry {
if (perScheme == null) {
return Optional.empty();
}
return Optional.ofNullable(perScheme.get(schemeId)).map(byte[]::clone);
final byte[] exact = perScheme.get(schemeId);
if (exact != null) {
return Optional.of(exact.clone());
}
// D-325 (2026-09-03): a registry written with an ALIAS of a scheme id ("slh-dsa-128s") answers a
// lookup by the canonical id ("slh-dsa-sha2-128s") and the other way round. Stored names are
// kept as written so the registry's canonical hash does not move under a file that did not.
final String wanted = canonicalId(schemeId);
for (final Map.Entry<String, byte[]> e : perScheme.entrySet()) {
if (canonicalId(e.getKey()).equals(wanted)) {
return Optional.of(e.getValue().clone());
}
}
return Optional.empty();
}
private static String canonicalId(final String schemeId) {
return SealSchemes.byId(schemeId).map(SealScheme::id).orElse(schemeId);
}
/** The 20-byte address bound to {@code index}, or empty. */
@ -196,7 +213,11 @@ public final class HybridSignerRegistry {
/** How many indices hold a key under {@code schemeId}. The arming gate for a scheme asks this:
* arming a K-of-N threshold under a scheme with coverage below K would be a chain stop. */
public int coverage(final String schemeId) {
return (int) keys.values().stream().filter(m -> m.containsKey(schemeId)).count();
final String wanted = canonicalId(schemeId);
return (int)
keys.values().stream()
.filter(m -> m.keySet().stream().anyMatch(k -> canonicalId(k).equals(wanted)))
.count();
}
/** The canonical hash: domain || chainId || count || per index asc: index, addr, schemeCount,

View File

@ -205,6 +205,30 @@ public final class PqAnchorConfig {
/** Environment variable twin of {@link #PROPERTY_ANCHOR_INTERVAL}. */
public static final String ENV_ANCHOR_INTERVAL = "AERE_PQ_ANCHOR_INTERVAL";
/**
* System property naming the INTERVAL SCHEDULE: "h:interval,h:interval". From each height h the
* certificate is carried every {@code interval} blocks instead of {@link #PROPERTY_ANCHOR_INTERVAL}.
* D-336 (2026-09-04): a hybrid anchor is ~53 KB, so at interval 32 the chain costs ~8.35 GB per
* month per node; a scheduled interval lets the fleet move to 128 at a coordinated height.
*/
public static final String PROPERTY_ANCHOR_INTERVAL_SCHEDULE = "aere.pq.anchorIntervalSchedule";
/** Environment variable twin of {@link #PROPERTY_ANCHOR_INTERVAL_SCHEDULE}. */
public static final String ENV_ANCHOR_INTERVAL_SCHEDULE = "AERE_PQ_ANCHOR_INTERVAL_SCHEDULE";
/**
* AERE ANCHOR V2 (2026-09-03): from this height every anchor header carries the SCHEME-TAGGED
* certificate (PqAnchorV2: Falcon-512 plus every extra scheme the scheme schedule names at the
* parent height, K seals per scheme) and vanityData is the v2 digest under "AERE-PQ-ANCHOR-2".
* Below it nothing changes. A v2 certificate is refused below this height and a v1 one from it,
* so the switch is a consensus fork: every node of the fleet must carry the same value, exactly
* like {@link #PROPERTY_ANCHOR_BLOCK}. Absent means never. Env: {@code AERE_PQ_ANCHOR_V2_BLOCK}.
*/
public static final String PROPERTY_ANCHOR_V2_BLOCK = "aere.pq.anchorV2Block";
/** Environment variable twin of {@link #PROPERTY_ANCHOR_V2_BLOCK}. */
public static final String ENV_ANCHOR_V2_BLOCK = "AERE_PQ_ANCHOR_V2_BLOCK";
/** Environment variable twin of {@link #PROPERTY_ANCHOR_BLOCK}. */
public static final String ENV_ANCHOR_BLOCK = "AERE_PQ_ANCHOR_BLOCK";
@ -248,6 +272,8 @@ public final class PqAnchorConfig {
{PROPERTY_MIN_SEALS_CEILING, ENV_MIN_SEALS_CEILING},
{PROPERTY_MAX_SEALS, ENV_MAX_SEALS},
{PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL},
{PROPERTY_ANCHOR_INTERVAL_SCHEDULE, ENV_ANCHOR_INTERVAL_SCHEDULE},
{PROPERTY_ANCHOR_V2_BLOCK, ENV_ANCHOR_V2_BLOCK},
{PROPERTY_DISABLE, ENV_DISABLE}
};
@ -260,6 +286,8 @@ public final class PqAnchorConfig {
private final boolean disabled;
private final OptionalInt maxSealsCarried;
private final OptionalInt anchorInterval;
private final long anchorV2Block;
private final NavigableMap<Long, Integer> anchorIntervalSchedule;
/**
* Build an explicit configuration.
@ -320,7 +348,99 @@ public final class PqAnchorConfig {
final boolean disabled,
final OptionalInt maxSealsCarried,
final OptionalInt anchorInterval) {
this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried,
anchorInterval, NEVER);
}
/**
* Full constructor.
*
* @param anchorV2Block the height from which anchor headers carry the scheme-tagged v2
* certificate and the v2 digest, or {@link #NEVER}
*/
public PqAnchorConfig(
final long chainId,
final long anchorBlock,
final Map<Long, Integer> minSealsSchedule,
final OptionalInt minSealsCeiling,
final boolean disabled,
final OptionalInt maxSealsCarried,
final OptionalInt anchorInterval,
final long anchorV2Block) {
this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried,
anchorInterval, anchorV2Block, new TreeMap<>());
}
/**
* Full constructor with the interval schedule (D-336).
*
* @param anchorIntervalSchedule heights from which the interval changes; every interval must be a
* positive multiple of the base interval and every height must sit on the NEW grid counted
* from H, so that the anchors of the new regime are a subset of the old ones and the change
* never invents an anchor height the old regime did not have
*/
private PqAnchorConfig(
final long chainId,
final long anchorBlock,
final Map<Long, Integer> minSealsSchedule,
final OptionalInt minSealsCeiling,
final boolean disabled,
final OptionalInt maxSealsCarried,
final OptionalInt anchorInterval,
final long anchorV2Block,
final NavigableMap<Long, Integer> anchorIntervalSchedule) {
this.anchorInterval = anchorInterval;
this.anchorV2Block = anchorV2Block;
this.anchorIntervalSchedule = new TreeMap<>(anchorIntervalSchedule);
if (!this.anchorIntervalSchedule.isEmpty()) {
if (anchorInterval.isEmpty()) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + " is set but "
+ PROPERTY_ANCHOR_INTERVAL + " is not: a schedule changes an interval, it cannot"
+ " create one");
}
if (anchorBlock == NEVER) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE
+ " is set but no anchor activation height is");
}
final int base = anchorInterval.getAsInt();
for (final Map.Entry<Long, Integer> e : this.anchorIntervalSchedule.entrySet()) {
final long h = e.getKey();
final int iv = e.getValue();
if (iv < 1 || iv % base != 0) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + " names interval " + iv
+ " at " + h + ", which is not a positive multiple of the base interval " + base
+ ": the anchors of the new regime must be a subset of the old ones");
}
if (h < anchorBlock || (h - anchorBlock) % iv != 0) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + " changes the interval to "
+ iv + " at " + h + ", which is not on the new grid counted from H=" + anchorBlock
+ " ((h - H) % " + iv + " must be 0): the first anchor of the new regime is the"
+ " activation height itself, so every node can check it by hand");
}
}
}
if (anchorV2Block != NEVER) {
if (anchorV2Block < 1) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: " + PROPERTY_ANCHOR_V2_BLOCK + " must be at least 1, got " + anchorV2Block);
}
if (anchorBlock == NEVER) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: " + PROPERTY_ANCHOR_V2_BLOCK + "=" + anchorV2Block
+ " is set but no anchor activation height is: a v2 certificate is a form of the"
+ " anchor certificate, it cannot exist where no anchor does");
}
if (anchorV2Block < anchorBlock) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: " + PROPERTY_ANCHOR_V2_BLOCK + "=" + anchorV2Block
+ " is below the anchor activation height " + anchorBlock
+ "; the v2 form cannot take effect before the anchor rules do");
}
}
if (anchorInterval.isPresent() && anchorInterval.getAsInt() < 1) {
throw new IllegalArgumentException(
"AERE PQ ANCHOR: "
@ -550,7 +670,10 @@ public final class PqAnchorConfig {
final PqAnchorConfig bare = strict(block, seals, chain, ceiling, false);
final PqAnchorConfig config =
bare.withMaxSealsCarried(readMaxSeals()).withAnchorInterval(readAnchorInterval());
bare.withMaxSealsCarried(readMaxSeals())
.withAnchorInterval(readAnchorInterval())
.withAnchorIntervalSchedule(readAnchorIntervalSchedule())
.withAnchorV2Block(readAnchorV2Block());
requireMinSealsFloorOrRefuse(config);
if (config.anchorInterval().isPresent() && config.everActive()) {
final int iv = config.anchorInterval().getAsInt();
@ -565,6 +688,13 @@ public final class PqAnchorConfig {
iv,
String.format("%.1f", 120.5 / iv));
}
if (!config.anchorIntervalSchedule().isEmpty() && config.everActive()) {
LOG.warn(
"AERE PQ ANCHOR: interval SCHEDULE in force - {} (height:interval). From each height the"
+ " certificate is carried every that-many blocks; the anchors of every later regime are"
+ " a subset of the earlier ones (D-336, disk).",
config.anchorIntervalSchedule());
}
if (config.maxSealsCarried().isPresent() && config.everActive()) {
LOG.warn(
"AERE PQ ANCHOR: proposer-side seal CAP in force - this node writes at most {} seal(s) "
@ -787,6 +917,66 @@ public final class PqAnchorConfig {
* is a refusal, never a silent fallback, because a weakening asked for and quietly dropped and a
* weakening quietly APPLIED are both states an operator must never be left guessing between.
*/
/**
* Read the v2 activation height. Absent means never; an unreadable value is a refusal, because
* this height is a consensus fork and a node guessing it differently from its peers rejects
* every anchor header its peers accept, or the other way round.
*/
private static long readAnchorV2Block() {
final Read v2 = read(PROPERTY_ANCHOR_V2_BLOCK, ENV_ANCHOR_V2_BLOCK);
if (!v2.hasValue()) {
return NEVER;
}
try {
return Long.parseLong(v2.raw().trim());
} catch (final NumberFormatException e) {
throw new IllegalArgumentException(
REFUSAL_CODE
+ ": "
+ PROPERTY_ANCHOR_V2_BLOCK
+ " is set to '"
+ v2.raw()
+ "' from "
+ v2.source()
+ ", which is not a whole number. It is NOT ignored: this height decides which form of"
+ " the anchor certificate is valid, and a node guessing it differently from its peers"
+ " parts from the chain at the first anchor.");
}
}
private static NavigableMap<Long, Integer> readAnchorIntervalSchedule() {
final Read sc = read(PROPERTY_ANCHOR_INTERVAL_SCHEDULE, ENV_ANCHOR_INTERVAL_SCHEDULE);
final NavigableMap<Long, Integer> out = new TreeMap<>();
if (!sc.hasValue()) {
return out;
}
for (final String part : Splitter.on(',').split(sc.raw())) {
final String p = part.trim();
if (p.isEmpty()) {
continue;
}
final int colon = p.indexOf(':');
try {
if (colon < 1) {
throw new NumberFormatException(p);
}
out.put(Long.parseLong(p.substring(0, colon).trim()), Integer.parseInt(p.substring(colon + 1).trim()));
} catch (final NumberFormatException e) {
throw new IllegalArgumentException(
REFUSAL_CODE
+ ": "
+ PROPERTY_ANCHOR_INTERVAL_SCHEDULE
+ " is set to '"
+ sc.raw()
+ "' from "
+ sc.source()
+ ", which is not a list of height:interval. It is NOT ignored: two nodes that read"
+ " it differently disagree about which headers carry a certificate.");
}
}
return out;
}
private static OptionalInt readAnchorInterval() {
final Read iv = read(PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL);
if (!iv.hasValue()) {
@ -1173,7 +1363,8 @@ public final class PqAnchorConfig {
// CAREFUL: the interval is carried across too. A copy method that drops a field along the way
// is exactly how a disarm or an interval would vanish silently when the other control is set.
return new PqAnchorConfig(
chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, cap, anchorInterval);
chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, cap, anchorInterval,
anchorV2Block);
}
/**
@ -1186,6 +1377,43 @@ public final class PqAnchorConfig {
return anchorInterval;
}
/**
* The interval in force at a height: the base interval, or the last scheduled change at or below
* the height (D-336).
*
* @param blockNumber the height
* @return the interval in force there; 1 when no interval is configured
*/
public int intervalAt(final long blockNumber) {
if (anchorInterval.isEmpty()) {
return 1;
}
final Map.Entry<Long, Integer> e = anchorIntervalSchedule.floorEntry(blockNumber);
return e == null ? anchorInterval.getAsInt() : e.getValue();
}
/**
* The interval schedule (D-336), possibly empty.
*
* @return an unmodifiable view, height to interval
*/
public NavigableMap<Long, Integer> anchorIntervalSchedule() {
return java.util.Collections.unmodifiableNavigableMap(anchorIntervalSchedule);
}
/**
* Return a copy carrying an interval schedule (D-336). Refused unless every entry keeps the new
* anchors a subset of the old ones; see the private constructor.
*
* @param schedule heights to intervals
* @return a copy of this configuration carrying the schedule
*/
public PqAnchorConfig withAnchorIntervalSchedule(final Map<Long, Integer> schedule) {
return new PqAnchorConfig(
chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried,
anchorInterval, anchorV2Block, new TreeMap<>(schedule));
}
/**
* Is this a height at which a certificate is carried and demanded?
*
@ -1203,7 +1431,7 @@ public final class PqAnchorConfig {
if (anchorBlock == NEVER || blockNumber < anchorBlock) {
return false;
}
return (blockNumber - anchorBlock) % anchorInterval.getAsInt() == 0;
return (blockNumber - anchorBlock) % intervalAt(blockNumber) == 0;
}
/**
@ -1222,6 +1450,39 @@ public final class PqAnchorConfig {
return activeAt(blockNumber) && isAnchorHeight(blockNumber);
}
/**
* The v2 activation height, or {@link #NEVER}.
*
* @return the height from which anchor headers carry the scheme-tagged certificate
*/
public long anchorV2Block() {
return anchorV2Block;
}
/**
* Whether the header at this height must carry the SCHEME-TAGGED (v2) certificate and the v2
* digest. True only at an anchor height ({@link #anchorAppliesAt}) at or above the v2 activation
* height. The producer and both rules ask THIS question, not one that resembles it.
*
* @param blockNumber the height
* @return true when the v2 form is the only accepted form at this height
*/
public boolean anchorV2AppliesAt(final long blockNumber) {
return anchorV2Block != NEVER && blockNumber >= anchorV2Block && anchorAppliesAt(blockNumber);
}
/**
* Copy with a v2 activation height.
*
* @param v2Block the height, or {@link #NEVER}
* @return a configuration identical to this one except for the v2 height
*/
public PqAnchorConfig withAnchorV2Block(final long v2Block) {
return new PqAnchorConfig(
chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried,
anchorInterval, v2Block, anchorIntervalSchedule);
}
/**
* Return a copy carrying an anchor interval.
*
@ -1237,7 +1498,8 @@ public final class PqAnchorConfig {
*/
public PqAnchorConfig withAnchorInterval(final OptionalInt interval) {
return new PqAnchorConfig(
chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, interval);
chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, interval,
anchorV2Block, anchorIntervalSchedule);
}
/**

View File

@ -167,11 +167,27 @@ public final class PqSealCache {
if (file == null) {
return 0;
}
final List<FalconSeal> verified;
List<FalconSeal> verified;
try {
verified =
PqSealStore.readVerified(
file, persistenceChainId, blockNumber, onchainBlockHash, registry);
if (verified.isEmpty()) {
// MAGAZIA TINE VARFUL (D-330): the main file may hold a later height that never imported;
// the previous slot may hold exactly the head. Same verification, same registry.
final Path previous = file.resolveSibling(PqSealStore.PREVIOUS_FILE_NAME);
verified =
PqSealStore.readVerified(
previous, persistenceChainId, blockNumber, onchainBlockHash, registry);
if (!verified.isEmpty()) {
LOG.info(
"AERE PERSISTENTA-SIGILII: {} seal(s) for the chain head {} restored from the PREVIOUS slot {}"
+ " (the main file held a later height).",
verified.size(),
blockNumber,
previous);
}
}
} catch (final RuntimeException e) {
// readVerified is written not to throw; this is the belt on top of the braces, because an
// exception escaping here would turn a lost cache into a node that refuses to start.
@ -253,6 +269,15 @@ public final class PqSealCache {
return;
}
try {
// MAGAZIA TINE VARFUL (D-330, 2026-09-03): before the main file moves to a HIGHER height, what it
// held becomes the previous slot. The main file may hold a proposal that never imports; the
// previous slot then still holds the head, and a restart can restore the head's seals from it.
if (persistedBlock >= 0 && blockNumber > persistedBlock && java.nio.file.Files.isRegularFile(file)) {
java.nio.file.Files.move(
file,
file.resolveSibling(PqSealStore.PREVIOUS_FILE_NAME),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
PqSealStore.writeAtomically(
file,
PqSealStore.encode(
@ -281,6 +306,60 @@ public final class PqSealCache {
* @param onchainBlockHash the ON-CHAIN hash of that block
* @return the seals, sorted by strictly increasing validator index, possibly empty
*/
/**
* AERE ANCHOR V2: remember the extra (non-Falcon) scheme seals heard on the commits of a block.
* Same key as {@link #record}, same window, same idempotence (first seal per validator and
* scheme wins). NOT persisted across a restart: a node that restarts holds no extras until it
* takes part in one commit, exactly the Falcon situation before PqSealStore, and the producer
* then refuses the v2 certificate for one proposer turn rather than writing a short one.
*
* @param blockNumber the height of the block the seals are over
* @param onchainBlockHash the round-independent on-chain hash of that block
* @param extraSeals the heard scheme seals, unverified
*/
public void recordExtras(
final long blockNumber, final Hash onchainBlockHash, final Collection<SchemeSeal> extraSeals) {
synchronized (this) {
if (onchainBlockHash == null || extraSeals == null || extraSeals.isEmpty()) {
return;
}
final Entry entry =
byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber));
for (final SchemeSeal seal : extraSeals) {
if (seal == null
|| seal.getValidatorIndex() < 0
|| seal.getSignature() == null
|| seal.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) {
continue;
}
final long key = ((long) seal.getValidatorIndex() << 8) | (seal.getSchemeWireId() & 0xffL);
entry.extras.putIfAbsent(key, seal);
}
if (blockNumber > highestSeen) {
highestSeen = blockNumber;
}
prune();
}
}
/**
* AERE ANCHOR V2: the extra scheme seals heard for a block, canonical order, unverified.
*
* @param blockNumber the height
* @param onchainBlockHash the round-independent on-chain hash
* @return the seals, or empty when nothing was heard under that key
*/
public synchronized List<SchemeSeal> extrasFor(
final long blockNumber, final Hash onchainBlockHash) {
final Entry entry = byHash.get(onchainBlockHash);
if (entry == null || entry.blockNumber != blockNumber) {
return List.of();
}
final List<SchemeSeal> out = new ArrayList<>(entry.extras.values());
out.sort(PqAnchorV2.CANONICAL);
return out;
}
public synchronized List<FalconSeal> sealsFor(
final long blockNumber, final Hash onchainBlockHash) {
final Entry entry = byHash.get(onchainBlockHash);
@ -342,6 +421,8 @@ public final class PqSealCache {
private static final class Entry {
private final long blockNumber;
private final Map<Integer, FalconSeal> seals = new LinkedHashMap<>();
/** AERE ANCHOR V2: heard extra (non-Falcon) scheme seals, keyed (index << 8 | wire). */
private final Map<Long, SchemeSeal> extras = new LinkedHashMap<>();
private Entry(final long blockNumber) {
this.blockNumber = blockNumber;

View File

@ -84,6 +84,14 @@ public final class PqSealStore {
/** The temporary file the atomic write goes through before the rename. */
public static final String TEMP_FILE_NAME = "aere-pq-seals.rlp.tmp";
/**
* D-330 (2026-09-03): the PREVIOUS slot. The main file holds the highest height heard, which can be
* a proposal that never imported (testnet 28001: the file held 168032 while the head was 168031,
* and after a fleet-wide restart every node had zero seals for the head). The previous slot keeps
* what the main file held before its last rotation, so the head's seals survive one extra height.
*/
public static final String PREVIOUS_FILE_NAME = "aere-pq-seals-anterior.rlp";
/** Domain label, so a file written for any other purpose cannot be read as a seal set. */
public static final String DOMAIN = "AERE-PQ-SEALSTORE-1";
@ -119,6 +127,16 @@ public final class PqSealStore {
return dataDirectory.resolve(FILE_NAME);
}
/**
* The previous-slot file beside {@link #fileIn}.
*
* @param dataDirectory the node data directory
* @return the previous slot's path
*/
public static Path previousFileIn(final Path dataDirectory) {
return dataDirectory.resolve(PREVIOUS_FILE_NAME);
}
/**
* The canonical bytes of a seal set.
*

View File

@ -32,8 +32,11 @@ public final class SealSchemes {
if (id == null) {
return Optional.empty();
}
// D-325: the SLH-DSA scheme is named "slh-dsa-sha2-128s" on both clients now; the old short id
// is still understood so an existing schedule string keeps meaning the same mathematics.
final String wanted = SlhDsaSealScheme.LEGACY_ID.equals(id) ? SLH_DSA_128S.id() : id;
for (final SealScheme s : ALL) {
if (s.id().equals(id)) {
if (s.id().equals(wanted)) {
return Optional.of(s);
}
}

View File

@ -13,14 +13,22 @@ import java.security.SecureRandom;
import java.util.Optional;
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAKeyGenerationParameters;
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAKeyPairGenerator;
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters;
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters;
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters;
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyGenerationParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyPairGenerator;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPrivateKeyParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPublicKeyParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSASigner;
/** SLH-DSA-128s (small, SHA2 family) as a pluggable seal scheme. */
/*
* D-337 (2026-09-04): the SLH-DSA classes come from the in-tree copy of Bouncy Castle's engine
* (package ..bft.slhdsa) whose SHA-2 runs on the JDK's SHA-NI intrinsics. Bouncy Castle's pure-Java
* digest made one sha2_128s signature cost 1.4-2.7 s on the validators' CPUs, on the QBFT thread, at
* every anchor parent, and the chain slowed from 0.56 to 0.76 s per block. Measured on the same CPUs
* the in-tree engine signs in 0.3-0.7 s; same keys, same bytes (SlhDsaFastEngineTest pins that
* against the original).
*/
public final class SlhDsaSealScheme implements SealScheme {
/** Registry form: the encoded SLH-DSA-128s public key (PK.seed || PK.root), 32 bytes. */
@ -32,11 +40,16 @@ public final class SlhDsaSealScheme implements SealScheme {
private record Priv(SLHDSAPrivateKeyParameters params) implements PrivateHandle {}
// D-325 (2026-09-03): the id the SECOND client and the public verifier use, "slh-dsa-sha2-128s";
// the shorter "slh-dsa-128s" stays accepted as an alias in SealSchemes.byId so no schedule breaks.
@Override
public String id() {
return "slh-dsa-128s";
return "slh-dsa-sha2-128s";
}
/** The alias this build accepted before D-325. */
public static final String LEGACY_ID = "slh-dsa-128s";
@Override
public byte wireId() {
return 0x02;

View File

@ -14,15 +14,23 @@
*/
package org.hyperledger.besu.consensus.common.bft.blockcreation;
import org.apache.tuweni.bytes.Bytes;
import org.hyperledger.besu.datatypes.Hash;
import org.hyperledger.besu.consensus.common.bft.BftContext;
import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
import org.hyperledger.besu.consensus.common.bft.HybridSealSupport;
import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry;
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException;
import org.hyperledger.besu.consensus.common.bft.PqAnchorV2;
import org.hyperledger.besu.consensus.common.bft.PqSealCache;
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
import org.hyperledger.besu.consensus.common.bft.SealScheme;
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.ProtocolContext;
import org.hyperledger.besu.ethereum.core.BlockHeader;
@ -31,6 +39,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
@ -156,6 +165,51 @@ public final class PqAnchorProducer {
return cfg.everActive() && blockNumber + 1L >= cfg.anchorBlock();
}
/**
* AERE D-311 (2026-09-02): THE ONE PLACE that says which bytes a commit's post-quantum seal
* signs. Both the emitter ({@code QbftRound.pqSealMessageFor}) and the verifier
* ({@code MessageValidator.SubsequentMessageValidator}, feeding {@code PqCommitEnforcement})
* call this and nothing else.
*
* <p>Why it exists, measured on the public testnet 28001 the day it was born: with the anchor
* armed (as on 2800 since 13,014,000) the emitter signed the anchor form, while the commit
* enforcement verified over the ECDSA committed-seal hash. Two copies of one rule, and they
* drifted: at {@code aere.pq.commitPq.forkBlock} every commit of every validator was refused
* ("does NOT verify over the commit digest") and the chain stopped. The rehearsal (F95) never
* saw it because its kit runs without the anchor armed. Arming H1 on 2800 with the old
* verifier would have stopped the live chain.
*
* @param blockNumber the height of the block the commit votes for
* @param onchainHash the ROUND-INDEPENDENT on-chain hash of that block (round forced to 0)
* @param commitHash the ECDSA committed-seal hash (round-specific)
* @return the 32 bytes the Falcon seal (and any hybrid extras) must sign at this height
*/
public static Bytes32 commitSealMessage(
final long blockNumber, final Bytes onchainHash, final Hash commitHash) {
return commitSealMessage(blockNumber, () -> onchainHash, commitHash);
}
/**
* Same as {@link #commitSealMessage(long, Bytes, Hash)}, with the on-chain hash supplied LAZILY:
* it is only needed (and only computed) in the anchor form. Below the anchor the ECDSA digest is
* the message and no round-0 re-encoding happens, which is also what keeps every upstream test
* that mocks {@code QbftBlockInterface} exactly as it was.
*
* @param blockNumber the height of the block the commit votes for
* @param onchainHash supplier of the ROUND-INDEPENDENT on-chain hash (round forced to 0)
* @param commitHash the ECDSA committed-seal hash (round-specific)
* @return the 32 bytes the Falcon seal (and any hybrid extras) must sign at this height
*/
public static Bytes32 commitSealMessage(
final long blockNumber,
final java.util.function.Supplier<Bytes> onchainHash,
final Hash commitHash) {
if (sealMessageIsAnchorForm(blockNumber)) {
return PqAnchor.commitMessage(config().chainId(), blockNumber, onchainHash.get());
}
return Bytes32.wrap(commitHash.getBytes());
}
/**
* Return the extra data the proposer should encode for a block on top of this parent.
*
@ -275,6 +329,9 @@ public final class PqAnchorProducer {
+ "refusing to write it. This is a bug in the producer, not a configuration fault.");
}
if (cfg.anchorV2AppliesAt(blockNumber)) {
return applyV2(cfg, blockNumber, parentHeader, base, certificate, message, required, sealCap);
}
final Bytes32 digest =
PqAnchor.anchorDigest(
cfg.chainId(), parentNumber, parentHeader.getHash().getBytes(), certificate);
@ -296,6 +353,126 @@ public final class PqAnchorProducer {
certificate);
}
/**
* AERE ANCHOR V2 (2026-09-03): the SCHEME-TAGGED certificate. Carries the Falcon seals already
* verified by the caller, tagged 0x01, plus, for every extra scheme the scheme schedule names at
* the PARENT height, the heard seals of that scheme that (a) come from an index that also holds a
* Falcon seal in this very certificate, the binding that stops a lone hash-based seal from
* standing in for a validator, and (b) verify under the hybrid registry's key for that index and
* scheme, over the SAME message M(parent) the Falcon seal signs. K seals per scheme, capped like
* Falcon. vanityData becomes the v2 digest under "AERE-PQ-ANCHOR-2". Refuses, through the same
* not-ready exception the v1 path uses, rather than writing a certificate short of a scheme.
*/
private static BftExtraData applyV2(
final PqAnchorConfig cfg,
final long blockNumber,
final BlockHeader parentHeader,
final BftExtraData base,
final List<FalconSeal> falconCertificate,
final Bytes32 message,
final int required,
final int sealCap) {
final long parentNumber = parentHeader.getNumber();
final List<SchemeSeal> v2 = new ArrayList<>();
final Set<Integer> falconIndices = new LinkedHashSet<>();
for (final FalconSeal f : falconCertificate) {
v2.add(new SchemeSeal(SealSchemes.FALCON_512.wireId(), f.getValidatorIndex(), f.getSignature()));
falconIndices.add(f.getValidatorIndex());
}
final HybridSealSupport hybrid = HybridSealSupport.instance();
final Set<String> schemes =
hybrid.schedule().map(sch -> sch.schemesAt(parentNumber)).orElse(Set.of());
final List<SchemeSeal> heard =
PqSealCache.instance().extrasFor(parentNumber, parentHeader.getHash());
for (final String schemeId : schemes) {
if (SealSchemes.FALCON_512.id().equals(schemeId)) {
continue;
}
final Optional<SealScheme> scheme = SealSchemes.byId(schemeId);
if (scheme.isEmpty()) {
throw new PqAnchorNotReadyException(
parentNumber,
0,
required,
"the scheme schedule names '" + schemeId + "', which this binary does not implement");
}
final Optional<HybridSignerRegistry> registry = hybrid.registry();
if (registry.isEmpty()) {
throw new PqAnchorNotReadyException(
parentNumber,
0,
required,
"no hybrid registry is loaded on this node, so no " + schemeId + " seal can be verified");
}
int kept = 0;
int rejected = 0;
for (final SchemeSeal s : heard) {
if (s.getSchemeWireId() != scheme.get().wireId()) {
continue;
}
if (!falconIndices.contains(s.getValidatorIndex())) {
rejected++;
continue;
}
final Optional<byte[]> pk = registry.get().publicKey(s.getValidatorIndex(), schemeId);
if (pk.isEmpty()
|| !scheme.get().verifyRaw(pk.get(), message.toArray(), s.getSignature().toArray())) {
rejected++;
continue;
}
v2.add(s);
kept++;
if (kept >= sealCap) {
break;
}
}
if (kept < required) {
throw new PqAnchorNotReadyException(
parentNumber,
kept,
required,
"V2: heard "
+ heard.size()
+ " extra seal(s) for parent hash "
+ parentHeader.getHash()
+ "; "
+ kept
+ " valid "
+ schemeId
+ " seal(s) bound to a Falcon-certified index, "
+ rejected
+ " rejected. The v2 certificate needs K="
+ required
+ " seals of EVERY scheme the schedule names at height "
+ parentNumber
+ " ("
+ String.join("+", schemes)
+ ").");
}
}
v2.sort(PqAnchorV2.CANONICAL);
final Bytes32 digest =
PqAnchorV2.anchorDigestV2(
cfg.chainId(), parentNumber, parentHeader.getHash().getBytes(), v2);
LOG.debug(
"AERE PQ-ANCHOR V2: block {} carries a {}-seal scheme-tagged certificate over parent {} "
+ "(K={} per scheme, schemes={}), vanity D2={}",
blockNumber,
v2.size(),
parentNumber,
required,
schemes,
digest);
return new BftExtraData(
digest,
base.getSeals(),
base.getVote(),
base.getRound(),
base.getValidators(),
List.of(),
v2);
}
/**
* The validator set FOR the parent block: the nodes that were entitled to seal it.
*

View File

@ -0,0 +1,115 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.Pack;
@SuppressWarnings("all")
class ADRS
{
static final int WOTS_HASH = 0;
static final int WOTS_PK = 1;
static final int TREE = 2;
static final int FORS_TREE = 3;
static final int FORS_PK = 4;
static final int WOTS_PRF = 5;
static final int FORS_PRF = 6;
static final int OFFSET_LAYER = 0;
static final int OFFSET_TREE = 4;
static final int OFFSET_TREE_HGT = 24;
static final int OFFSET_TREE_INDEX = 28;
static final int OFFSET_TYPE = 16;
static final int OFFSET_KP_ADDR = 20;
static final int OFFSET_CHAIN_ADDR = 24;
static final int OFFSET_HASH_ADDR = 28;
final byte[] value = new byte[32];
ADRS()
{
}
ADRS(ADRS adrs)
{
System.arraycopy(adrs.value, 0, this.value, 0, adrs.value.length);
}
public void setLayerAddress(int layer)
{
Pack.intToBigEndian(layer, value, OFFSET_LAYER);
}
public int getLayerAddress()
{
return Pack.bigEndianToInt(value, OFFSET_LAYER);
}
public void setTreeAddress(long tree)
{
// tree address is 12 bytes
Pack.longToBigEndian(tree, value, OFFSET_TREE + 4);
}
public long getTreeAddress()
{
return Pack.bigEndianToLong(value, OFFSET_TREE + 4);
}
public void setTreeHeight(int height)
{
Pack.intToBigEndian(height, value, OFFSET_TREE_HGT);
}
public void setTreeIndex(int index)
{
Pack.intToBigEndian(index, value, OFFSET_TREE_INDEX);
}
public int getTreeIndex()
{
return Pack.bigEndianToInt(value, OFFSET_TREE_INDEX);
}
// resets part of value to zero in line with 2.7.3
public void setTypeAndClear(int type)
{
Pack.intToBigEndian(type, value, OFFSET_TYPE);
Arrays.fill(value, 20, value.length, (byte)0);
}
public void changeType(int type)
{
Pack.intToBigEndian(type, value, OFFSET_TYPE);
}
public void setKeyPairAddress(int keyPairAddr)
{
Pack.intToBigEndian(keyPairAddr, value, OFFSET_KP_ADDR);
}
public int getKeyPairAddress()
{
return Pack.bigEndianToInt(value, OFFSET_KP_ADDR);
}
public void setHashAddress(int hashAddr)
{
Pack.intToBigEndian(hashAddr, value, OFFSET_HASH_ADDR);
}
public void setChainAddress(int chainAddr)
{
Pack.intToBigEndian(chainAddr, value, OFFSET_CHAIN_ADDR);
}
}

View File

@ -0,0 +1,178 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import java.math.BigInteger;
import java.util.LinkedList;
import org.bouncycastle.util.Arrays;
@SuppressWarnings("all")
class Fors
{
SLHDSAEngine engine;
public Fors(SLHDSAEngine engine)
{
this.engine = engine;
}
// Input: Secret seed SK.seed, start index s, target node height z, public seed PK.seed, address ADRS
// Output: n-byte root node - top node on Stack
byte[] treehash(byte[] skSeed, int s, int z, byte[] pkSeed, ADRS adrsParam)
{
if ((s >>> z) << z != s)
{
return null;
}
LinkedList<NodeEntry> stack = new LinkedList<NodeEntry>();
ADRS adrs = new ADRS(adrsParam);
for (int idx = 0; idx < (1 << z); idx++)
{
adrs.setTypeAndClear(ADRS.FORS_PRF);
adrs.setKeyPairAddress(adrsParam.getKeyPairAddress());
adrs.setTreeHeight(0);
adrs.setTreeIndex(s + idx);
byte[] sk = engine.PRF(pkSeed, skSeed, adrs);
adrs.changeType(ADRS.FORS_TREE);
byte[] node = engine.F(pkSeed, adrs, sk);
adrs.setTreeHeight(1);
int adrsTreeHeight = 1;
int adrsTreeIndex = s + idx;
// while ( Top node on Stack has same height as node )
while (!stack.isEmpty() && stack.get(0).nodeHeight == adrsTreeHeight)
{
adrsTreeIndex = (adrsTreeIndex - 1) / 2;
adrs.setTreeIndex(adrsTreeIndex);
NodeEntry current = stack.remove(0);
node = engine.H(pkSeed, adrs, current.nodeValue, node);
// topmost node is now one layer higher
adrs.setTreeHeight(++adrsTreeHeight);
}
stack.add(0, new NodeEntry(node, adrsTreeHeight));
}
return stack.get(0).nodeValue;
}
public SIG_FORS[] sign(byte[] md, byte[] skSeed, byte[] pkSeed, ADRS paramAdrs)
{
ADRS adrs = new ADRS(paramAdrs);
// int[] idxs = message_to_idxs(md, engine.K, engine.A);
int[] idxs = base2B(md, engine.A, engine.K);
SIG_FORS[] sig_fors = new SIG_FORS[engine.K];
// compute signature elements
int t = engine.T;
for (int i = 0; i < engine.K; i++)
{
// get next index
int idx = idxs[i];
// pick private key element
adrs.setTypeAndClear(ADRS.FORS_PRF);
adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress());
adrs.setTreeHeight(0);
adrs.setTreeIndex(i * t + idx);
byte[] sk = engine.PRF(pkSeed, skSeed, adrs);
adrs.changeType(ADRS.FORS_TREE);
byte[][] authPath = new byte[engine.A][];
// compute auth path
for (int j = 0; j < engine.A; j++)
{
int s = (idx / (1 << j)) ^ 1;
authPath[j] = treehash(skSeed, i * t + s * (1 << j), j, pkSeed, adrs);
}
sig_fors[i] = new SIG_FORS(sk, authPath);
}
return sig_fors;
}
public byte[] pkFromSig(SIG_FORS[] sig_fors, byte[] message, byte[] pkSeed, ADRS adrs)
{
byte[][] node = new byte[2][];
byte[][] root = new byte[engine.K][];
int t = engine.T;
// int[] idxs = message_to_idxs(message, engine.K, engine.A);
int[] idxs = base2B(message, engine.A, engine.K);
// compute roots
for (int i = 0; i < engine.K; i++)
{
// get next index
int idx = idxs[i];
// compute leaf
byte[] sk = sig_fors[i].getSK();
adrs.setTreeHeight(0);
adrs.setTreeIndex(i * t + idx);
node[0] = engine.F(pkSeed, adrs, sk);
// compute root from leaf and AUTH
byte[][] authPath = sig_fors[i].getAuthPath();
adrs.setTreeIndex(i * t + idx);
for (int j = 0; j < engine.A; j++)
{
adrs.setTreeHeight(j + 1);
if (((idx / (1 << j)) % 2) == 0)
{
adrs.setTreeIndex(adrs.getTreeIndex() / 2);
node[1] = engine.H(pkSeed, adrs, node[0], authPath[j]);
}
else
{
adrs.setTreeIndex((adrs.getTreeIndex() - 1) / 2);
node[1] = engine.H(pkSeed, adrs, authPath[j], node[0]);
}
node[0] = node[1];
}
root[i] = node[0];
}
ADRS forspkADRS = new ADRS(adrs); // copy address to create FTS public key address
forspkADRS.setTypeAndClear(ADRS.FORS_PK);
forspkADRS.setKeyPairAddress(adrs.getKeyPairAddress());
return engine.T_l(pkSeed, forspkADRS, Arrays.concatenate(root));
}
static int[] base2B(byte[] msg, int b, int outLen)
{
int[] baseB = new int[outLen];
int i = 0;
int bits = 0;
BigInteger total = BigInteger.ZERO;
for (int o = 0; o < outLen; o++)
{
while (bits < b)
{
total = total.shiftLeft(8).add(BigInteger.valueOf(msg[i] & 0xff));
i+= 1;
bits += 8;
}
bits -= b;
baseB[o] = (total.shiftRight(bits).mod(BigInteger.valueOf(2).pow(b))).intValue();
}
return baseB;
}
}

View File

@ -0,0 +1,225 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import java.util.LinkedList;
import org.bouncycastle.util.Arrays;
@SuppressWarnings("all")
class HT
{
private final byte[] skSeed;
private final byte[] pkSeed;
SLHDSAEngine engine;
WotsPlus wots;
final byte[] htPubKey;
public HT(SLHDSAEngine engine, byte[] skSeed, byte[] pkSeed)
{
this.skSeed = skSeed;
this.pkSeed = pkSeed;
this.engine = engine;
this.wots = new WotsPlus(engine);
ADRS adrs = new ADRS();
adrs.setLayerAddress(engine.D - 1);
adrs.setTreeAddress(0);
if (skSeed != null)
{
htPubKey = xmss_PKgen(skSeed, pkSeed, adrs);
}
else
{
htPubKey = null;
}
}
byte[] sign(byte[] M, long idx_tree, int idx_leaf)
{
// init
ADRS adrs = new ADRS();
// sign
// adrs.setType(ADRS.TREE);
adrs.setLayerAddress(0);
adrs.setTreeAddress(idx_tree);
SIG_XMSS SIG_tmp = xmss_sign(M, skSeed, idx_leaf, pkSeed, adrs);
SIG_XMSS[] SIG_HT = new SIG_XMSS[engine.D];
SIG_HT[0] = SIG_tmp;
adrs.setLayerAddress(0);
adrs.setTreeAddress(idx_tree);
byte[] root = xmss_pkFromSig(idx_leaf, SIG_tmp, M, pkSeed, adrs);
for (int j = 1; j < engine.D; j++)
{
idx_leaf = (int)(idx_tree & ((1 << engine.H_PRIME) - 1)); // least significant bits of idx_tree;
idx_tree >>>= engine.H_PRIME; // most significant bits of idx_tree;
adrs.setLayerAddress(j);
adrs.setTreeAddress(idx_tree);
SIG_tmp = xmss_sign(root, skSeed, idx_leaf, pkSeed, adrs);
SIG_HT[j] = SIG_tmp;
if (j < engine.D - 1)
{
root = xmss_pkFromSig(idx_leaf, SIG_tmp, root, pkSeed, adrs);
}
}
byte[][] totSigs = new byte[SIG_HT.length][];
for (int i = 0; i != totSigs.length; i++)
{
totSigs[i] = Arrays.concatenate(SIG_HT[i].sig, Arrays.concatenate(SIG_HT[i].auth));
}
return Arrays.concatenate(totSigs);
}
byte[] xmss_PKgen(byte[] skSeed, byte[] pkSeed, ADRS adrs)
{
return treehash(skSeed, 0, engine.H_PRIME, pkSeed, adrs);
}
// Input: index idx, XMSS signature SIG_XMSS = (sig || AUTH), n-byte message M, public seed PK.seed, address ADRS
// Output: n-byte root value node[0]
byte[] xmss_pkFromSig(int idx, SIG_XMSS sig_xmss, byte[] M, byte[] pkSeed, ADRS paramAdrs)
{
ADRS adrs = new ADRS(paramAdrs);
// compute WOTS+ pk from WOTS+ sig
adrs.setTypeAndClear(ADRS.WOTS_HASH);
adrs.setKeyPairAddress(idx);
byte[] sig = sig_xmss.getWOTSSig();
byte[][] AUTH = sig_xmss.getXMSSAUTH();
byte[] node0 = wots.pkFromSig(sig, M, pkSeed, adrs);
byte[] node1 = null;
// compute root from WOTS+ pk and AUTH
adrs.setTypeAndClear(ADRS.TREE);
adrs.setTreeIndex(idx);
for (int k = 0; k < engine.H_PRIME; k++)
{
adrs.setTreeHeight(k + 1);
if (((idx / (1 << k)) % 2) == 0)
{
adrs.setTreeIndex(adrs.getTreeIndex() / 2);
node1 = engine.H(pkSeed, adrs, node0, AUTH[k]);
}
else
{
adrs.setTreeIndex((adrs.getTreeIndex() - 1) / 2);
node1 = engine.H(pkSeed, adrs, AUTH[k], node0);
}
node0 = node1;
}
return node0;
}
// # Input: n-byte message M, secret seed SK.seed, index idx, public seed PK.seed,
// address ADRS
// # Output: XMSS signature SIG_XMSS = (sig || AUTH)
SIG_XMSS xmss_sign(byte[] M, byte[] skSeed, int idx, byte[] pkSeed, ADRS paramAdrs)
{
byte[][] AUTH = new byte[engine.H_PRIME][];
ADRS adrs = new ADRS(paramAdrs);
adrs.setTypeAndClear(ADRS.TREE);
adrs.setLayerAddress(paramAdrs.getLayerAddress());
adrs.setTreeAddress(paramAdrs.getTreeAddress());
// build authentication path
for (int j = 0; j < engine.H_PRIME; j++)
{
int k = (idx >>> j) ^ 1;
AUTH[j] = treehash(skSeed, k << j, j, pkSeed, adrs);
}
adrs = new ADRS(paramAdrs);
adrs.setTypeAndClear(ADRS.WOTS_HASH);
adrs.setKeyPairAddress(idx);
byte[] sig = wots.sign(M, skSeed, pkSeed, adrs);
return new SIG_XMSS(sig, AUTH);
}
// Input: Secret seed SK.seed, start index s, target node height z, public seed PK.seed, address ADRS
// Output: n-byte root node - top node on Stack
byte[] treehash(byte[] skSeed, int s, int z, byte[] pkSeed, ADRS adrsParam)
{
if ((s >>> z) << z != s)
{
return null;
}
LinkedList<NodeEntry> stack = new LinkedList<NodeEntry>();
ADRS adrs = new ADRS(adrsParam);
for (int idx = 0; idx < (1 << z); idx++)
{
adrs.setTypeAndClear(ADRS.WOTS_HASH);
adrs.setKeyPairAddress(s + idx);
byte[] node = wots.pkGen(skSeed, pkSeed, adrs);
adrs.setTypeAndClear(ADRS.TREE);
adrs.setTreeHeight(1);
adrs.setTreeIndex(s + idx);
int adrsTreeHeight = 1;
int adrsTreeIndex = s + idx;
// while ( Top node on Stack has same height as node )
while (!stack.isEmpty() && stack.get(0).nodeHeight == adrsTreeHeight)
{
adrsTreeIndex = (adrsTreeIndex - 1) / 2;
adrs.setTreeIndex(adrsTreeIndex);
NodeEntry current = stack.remove(0);
node = engine.H(pkSeed, adrs, current.nodeValue, node);
// topmost node is now one layer higher
adrs.setTreeHeight(++adrsTreeHeight);
}
stack.add(0, new NodeEntry(node, adrsTreeHeight));
}
return stack.get(0).nodeValue;
}
// # Input: Message M, signature SIG_HT, public seed PK.seed, tree index idx_tree,
// leaf index idx_leaf, HT public key PK_HT.
// # Output: Boolean
public boolean verify(byte[] M, SIG_XMSS[] sig_ht, byte[] pkSeed, long idx_tree, int idx_leaf, byte[] PK_HT)
{
// init
ADRS adrs = new ADRS();
// verify
SIG_XMSS SIG_tmp = sig_ht[0];
adrs.setLayerAddress(0);
adrs.setTreeAddress(idx_tree);
byte[] node = xmss_pkFromSig(idx_leaf, SIG_tmp, M, pkSeed, adrs);
for (int j = 1; j < engine.D; j++)
{
idx_leaf = (int)(idx_tree & ((1 << engine.H_PRIME) - 1)); // least significant bits of idx_tree;
idx_tree >>>= engine.H_PRIME; // most significant bits of idx_tree;
SIG_tmp = sig_ht[j];
adrs.setLayerAddress(j);
adrs.setTreeAddress(idx_tree);
node = xmss_pkFromSig(idx_leaf, SIG_tmp, node, pkSeed, adrs);
}
return Arrays.areEqual(PK_HT, node);
}
}

View File

@ -0,0 +1,26 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
class IndexedDigest
{
final long idx_tree;
final int idx_leaf;
final byte[] digest;
IndexedDigest(long idx_tree, int idx_leaf, byte[] digest)
{
this.idx_tree = idx_tree;
this.idx_leaf = idx_leaf;
this.digest = digest;
}
}

View File

@ -0,0 +1,104 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import java.security.DigestException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.bouncycastle.crypto.ExtendedDigest;
import org.bouncycastle.util.Memoable;
/**
* A Bouncy Castle {@link ExtendedDigest} backed by the JDK's {@link MessageDigest}, so SLH-DSA's
* millions of short hashes run on the SHA-NI intrinsics. Measured 2026-09-04 on an EPYC host: 3M
* SHA-256 of 64 bytes, Bouncy Castle 2533 ms, JDK 335 ms; one sha2_128s signature 1364 ms before,
* 325 ms after. {@link Memoable} is how the SLH-DSA engine keeps the pk-seed prefix state: it is
* implemented here with {@link MessageDigest#clone()}, which the SUN provider supports for SHA-2.
*/
@SuppressWarnings("all")
final class JdkDigest implements ExtendedDigest, Memoable {
private final String algorithm;
private final int blockLength;
private MessageDigest md;
JdkDigest(final String algorithm, final int blockLength) {
this.algorithm = algorithm;
this.blockLength = blockLength;
try {
this.md = MessageDigest.getInstance(algorithm);
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException("JDK has no " + algorithm, e);
}
}
private JdkDigest(final JdkDigest other) {
this.algorithm = other.algorithm;
this.blockLength = other.blockLength;
this.md = cloneOf(other.md);
}
private static MessageDigest cloneOf(final MessageDigest d) {
try {
return (MessageDigest) d.clone();
} catch (final CloneNotSupportedException e) {
throw new IllegalStateException(d.getAlgorithm() + " is not cloneable", e);
}
}
@Override
public String getAlgorithmName() {
return algorithm;
}
@Override
public int getDigestSize() {
return md.getDigestLength();
}
@Override
public int getByteLength() {
return blockLength;
}
@Override
public void update(final byte in) {
md.update(in);
}
@Override
public void update(final byte[] in, final int inOff, final int len) {
md.update(in, inOff, len);
}
@Override
public int doFinal(final byte[] out, final int outOff) {
try {
return md.digest(out, outOff, md.getDigestLength());
} catch (final DigestException e) {
throw new IllegalStateException(e);
}
}
@Override
public void reset() {
md.reset();
}
@Override
public Memoable copy() {
return new JdkDigest(this);
}
@Override
public void reset(final Memoable other) {
this.md = cloneOf(((JdkDigest) other).md);
}
}

View File

@ -0,0 +1,22 @@
Licenta sub care ne este ingaduit sa purtam in arborele nostru motorul SLH-DSA generat de
genereaza-motorul.py, adica fisierele din
consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/.
Sursa: bcprov-jdk18on-1.80-sources.jar, sha256 29e8414b7a07060b07222bb786dd594d2e411de0d7723d2fac8adedb0801cbef,
fisierul org/bouncycastle/LICENSE.java. Licenta e MIT, compatibila cu Apache-2.0 a lui Besu.
Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,24 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
class NodeEntry
{
final byte[] nodeValue;
final int nodeHeight;
NodeEntry(byte[] nodeValue, int nodeHeight)
{
this.nodeValue = nodeValue;
this.nodeHeight = nodeHeight;
}
}

View File

@ -0,0 +1,24 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
class PK
{
final byte[] seed;
final byte[] root;
PK(byte[] seed, byte[] root)
{
this.seed = seed;
this.root = root;
}
}

View File

@ -0,0 +1,77 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
class SIG
{
private final byte[] r;
private final SIG_FORS[] sig_fors;
private final SIG_XMSS[] sig_ht;
public SIG(int n, int k, int a, int d, int hPrime, int wots_len, byte[] signature)
{
this.r = new byte[n];
System.arraycopy(signature, 0, r, 0, n);
this.sig_fors = new SIG_FORS[k];
int offset = n;
for (int i = 0; i != k; i++)
{
byte[] sk = new byte[n];
System.arraycopy(signature, offset, sk, 0, n);
offset += n;
byte[][] authPath = new byte[a][];
for (int j = 0; j != a; j++)
{
authPath[j] = new byte[n];
System.arraycopy(signature, offset, authPath[j], 0, n);
offset += n;
}
sig_fors[i] = new SIG_FORS(sk, authPath);
}
sig_ht = new SIG_XMSS[d];
for (int i = 0; i != d; i++)
{
byte[] sig = new byte[wots_len * n];
System.arraycopy(signature, offset, sig, 0, sig.length);
offset += sig.length;
byte[][] authPath = new byte[hPrime][];
for (int j = 0; j != hPrime; j++)
{
authPath[j] = new byte[n];
System.arraycopy(signature, offset, authPath[j], 0, n);
offset += n;
}
sig_ht[i] = new SIG_XMSS(sig, authPath);
}
if (offset != signature.length)
{
throw new IllegalArgumentException("signature wrong length");
}
}
public byte[] getR()
{
return r;
}
public SIG_FORS[] getSIG_FORS()
{
return sig_fors;
}
public SIG_XMSS[] getSIG_HT()
{
return sig_ht;
}
}

View File

@ -0,0 +1,34 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
class SIG_FORS
{
final byte[][] authPath;
final byte[] sk;
SIG_FORS(byte[] sk, byte[][] authPath)
{
this.authPath = authPath;
this.sk = sk;
}
byte[] getSK()
{
return sk;
}
public byte[][] getAuthPath()
{
return authPath;
}
}

View File

@ -0,0 +1,34 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
class SIG_XMSS
{
final byte[] sig;
final byte[][] auth;
public SIG_XMSS(byte[] sig, byte[][] auth)
{
this.sig = sig;
this.auth = auth;
}
public byte[] getWOTSSig()
{
return sig;
}
public byte[][] getXMSSAUTH()
{
return auth;
}
}

View File

@ -0,0 +1,24 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
class SK
{
final byte[] seed;
final byte[] prf;
SK(byte[] seed, byte[] prf)
{
this.seed = seed;
this.prf = prf;
}
}

View File

@ -0,0 +1,466 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.Xof;
import org.bouncycastle.crypto.digests.SHAKEDigest;
import org.bouncycastle.crypto.generators.MGF1BytesGenerator;
import org.bouncycastle.crypto.macs.HMac;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.crypto.params.MGFParameters;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.Bytes;
import org.bouncycastle.util.Memoable;
import org.bouncycastle.util.Pack;
@SuppressWarnings("all")
abstract class SLHDSAEngine
{
final int N;
final int WOTS_W;
final int WOTS_LOGW;
final int WOTS_LEN;
final int WOTS_LEN1;
final int WOTS_LEN2;
final int D;
final int A; // FORS_HEIGHT
final int K; // FORS_TREES
final int H; // FULL_HEIGHT
final int H_PRIME; // H / D
final int T; // T = 1 << A
public SLHDSAEngine(int n, int w, int d, int a, int k, int h)
{
this.N = n;
/* SPX_WOTS_LEN2 is floor(log(len_1 * (w - 1)) / log(w)) + 1; we precompute */
if (w == 16)
{
WOTS_LOGW = 4;
WOTS_LEN1 = (8 * N / WOTS_LOGW);
if (N <= 8)
{
WOTS_LEN2 = 2;
}
else if (N <= 136)
{
WOTS_LEN2 = 3;
}
else if (N <= 256)
{
WOTS_LEN2 = 4;
}
else
{
throw new IllegalArgumentException("cannot precompute SPX_WOTS_LEN2 for n outside {2, .., 256}");
}
}
else if (w == 256)
{
WOTS_LOGW = 8;
WOTS_LEN1 = (8 * N / WOTS_LOGW);
if (N <= 1)
{
WOTS_LEN2 = 1;
}
else if (N <= 256)
{
WOTS_LEN2 = 2;
}
else
{
throw new IllegalArgumentException("cannot precompute SPX_WOTS_LEN2 for n outside {2, .., 256}");
}
}
else
{
throw new IllegalArgumentException("wots_w assumed 16 or 256");
}
this.WOTS_W = w;
this.WOTS_LEN = WOTS_LEN1 + WOTS_LEN2;
this.D = d;
this.A = a;
this.K = k;
this.H = h;
this.H_PRIME = h / d;
this.T = 1 << a;
}
abstract void init(byte[] pkSeed);
abstract byte[] F(byte[] pkSeed, ADRS adrs, byte[] m1);
abstract byte[] H(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2);
abstract IndexedDigest H_msg(byte[] prf, byte[] pkSeed, byte[] pkRoot, byte[] msgPrefix, byte[] msg);
abstract byte[] T_l(byte[] pkSeed, ADRS adrs, byte[] m);
abstract byte[] PRF(byte[] pkSeed, byte[] skSeed, ADRS adrs);
abstract byte[] PRF_msg(byte[] prf, byte[] randomiser, byte[] msgPrefix, byte[] msg);
static class Sha2Engine
extends SLHDSAEngine
{
private final HMac treeHMac;
private final MGF1BytesGenerator mgf1;
private final byte[] hmacBuf;
private final Digest msgDigest;
private final byte[] msgDigestBuf;
private final int bl;
private final Digest sha256 = new JdkDigest("SHA-256", 64);
private final byte[] sha256Buf = new byte[sha256.getDigestSize()];
private Memoable msgMemo;
private Memoable sha256Memo;
public Sha2Engine(int n, int w, int d, int a, int k, int h)
{
super(n, w, d, a, k, h);
if (n == 16)
{
this.msgDigest = new JdkDigest("SHA-256", 64);
this.treeHMac = new HMac(new JdkDigest("SHA-256", 64));
this.mgf1 = new MGF1BytesGenerator(new JdkDigest("SHA-256", 64));
this.bl = 64;
}
else
{
this.msgDigest = new JdkDigest("SHA-512", 128);
this.treeHMac = new HMac(new JdkDigest("SHA-512", 128));
this.mgf1 = new MGF1BytesGenerator(new JdkDigest("SHA-512", 128));
this.bl = 128;
}
this.hmacBuf = new byte[treeHMac.getMacSize()];
this.msgDigestBuf = new byte[msgDigest.getDigestSize()];
}
void init(byte[] pkSeed)
{
final byte[] padding = new byte[bl];
msgDigest.update(pkSeed, 0, pkSeed.length);
msgDigest.update(padding, 0, bl - N); // toByte(0, 64 - n)
msgMemo = ((Memoable)msgDigest).copy();
msgDigest.reset();
sha256.update(pkSeed, 0, pkSeed.length);
sha256.update(padding, 0, 64 - pkSeed.length); // toByte(0, 64 - n)
sha256Memo = ((Memoable)sha256).copy();
sha256.reset();
}
public byte[] F(byte[] pkSeed, ADRS adrs, byte[] m1)
{
byte[] compressedADRS = compressedADRS(adrs);
((Memoable)sha256).reset(sha256Memo);
sha256.update(compressedADRS, 0, compressedADRS.length);
sha256.update(m1, 0, m1.length);
sha256.doFinal(sha256Buf, 0);
return Arrays.copyOfRange(sha256Buf, 0, N);
}
public byte[] H(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2)
{
byte[] compressedADRS = compressedADRS(adrs);
((Memoable)msgDigest).reset(msgMemo);
msgDigest.update(compressedADRS, 0, compressedADRS.length);
msgDigest.update(m1, 0, m1.length);
msgDigest.update(m2, 0, m2.length);
msgDigest.doFinal(msgDigestBuf, 0);
return Arrays.copyOfRange(msgDigestBuf, 0, N);
}
IndexedDigest H_msg(byte[] prf, byte[] pkSeed, byte[] pkRoot, byte[] msgPrefix, byte[] msg)
{
int forsMsgBytes = ((A * K) + 7) / 8;
int leafBits = H / D;
int treeBits = H - leafBits;
int leafBytes = (leafBits + 7) / 8;
int treeBytes = (treeBits + 7) / 8;
int m = forsMsgBytes + leafBytes + treeBytes;
byte[] out = new byte[m];
byte[] dig = new byte[msgDigest.getDigestSize()];
msgDigest.update(prf, 0, prf.length);
msgDigest.update(pkSeed, 0, pkSeed.length);
msgDigest.update(pkRoot, 0, pkRoot.length);
if (msgPrefix != null)
{
msgDigest.update(msgPrefix, 0, msgPrefix.length);
}
msgDigest.update(msg, 0, msg.length);
msgDigest.doFinal(dig, 0);
out = bitmask(Arrays.concatenate(prf, pkSeed, dig), out);
// tree index
// currently, only indexes up to 64 bits are supported
byte[] treeIndexBuf = new byte[8];
System.arraycopy(out, forsMsgBytes, treeIndexBuf, 8 - treeBytes, treeBytes);
long treeIndex = Pack.bigEndianToLong(treeIndexBuf, 0);
treeIndex &= (~0L) >>> (64 - treeBits);
byte[] leafIndexBuf = new byte[4];
System.arraycopy(out, forsMsgBytes + treeBytes, leafIndexBuf, 4 - leafBytes, leafBytes);
int leafIndex = Pack.bigEndianToInt(leafIndexBuf, 0);
leafIndex &= (~0) >>> (32 - leafBits);
return new IndexedDigest(treeIndex, leafIndex, Arrays.copyOfRange(out, 0, forsMsgBytes));
}
public byte[] T_l(byte[] pkSeed, ADRS adrs, byte[] m)
{
byte[] compressedADRS = compressedADRS(adrs);
((Memoable)msgDigest).reset(msgMemo);
msgDigest.update(compressedADRS, 0, compressedADRS.length);
msgDigest.update(m, 0, m.length);
msgDigest.doFinal(msgDigestBuf, 0);
return Arrays.copyOfRange(msgDigestBuf, 0, N);
}
byte[] PRF(byte[] pkSeed, byte[] skSeed, ADRS adrs)
{
int n = skSeed.length;
((Memoable)sha256).reset(sha256Memo);
byte[] compressedADRS = compressedADRS(adrs);
sha256.update(compressedADRS, 0, compressedADRS.length);
sha256.update(skSeed, 0, skSeed.length);
sha256.doFinal(sha256Buf, 0);
return Arrays.copyOfRange(sha256Buf, 0, n);
}
public byte[] PRF_msg(byte[] prf, byte[] randomiser, byte[] msgPrefix, byte[] msg)
{
treeHMac.init(new KeyParameter(prf));
treeHMac.update(randomiser, 0, randomiser.length);
if (msgPrefix != null)
{
treeHMac.update(msgPrefix, 0, msgPrefix.length);
}
treeHMac.update(msg, 0, msg.length);
treeHMac.doFinal(hmacBuf, 0);
return Arrays.copyOfRange(hmacBuf, 0, N);
}
private byte[] compressedADRS(ADRS adrs)
{
byte[] rv = new byte[22];
System.arraycopy(adrs.value, ADRS.OFFSET_LAYER + 3, rv, 0, 1); // LSB layer address
System.arraycopy(adrs.value, ADRS.OFFSET_TREE + 4, rv, 1, 8); // LS 8 bytes Tree address
System.arraycopy(adrs.value, ADRS.OFFSET_TYPE + 3, rv, 9, 1); // LSB type
System.arraycopy(adrs.value, 20, rv, 10, 12);
return rv;
}
protected byte[] bitmask(byte[] key, byte[] m)
{
byte[] mask = new byte[m.length];
mgf1.init(new MGFParameters(key));
mgf1.generateBytes(mask, 0, mask.length);
Bytes.xorTo(m.length, m, mask);
return mask;
}
protected byte[] bitmask(byte[] key, byte[] m1, byte[] m2)
{
byte[] mask = new byte[m1.length + m2.length];
mgf1.init(new MGFParameters(key));
mgf1.generateBytes(mask, 0, mask.length);
Bytes.xorTo(m1.length, m1, mask);
Bytes.xorTo(m2.length, m2, 0, mask, m1.length);
return mask;
}
protected byte[] bitmask256(byte[] key, byte[] m)
{
byte[] mask = new byte[m.length];
MGF1BytesGenerator mgf1 = new MGF1BytesGenerator(new JdkDigest("SHA-256", 64));
mgf1.init(new MGFParameters(key));
mgf1.generateBytes(mask, 0, mask.length);
Bytes.xorTo(m.length, m, mask);
return mask;
}
}
static class Shake256Engine
extends SLHDSAEngine
{
private final Xof treeDigest;
private final Xof maskDigest;
public Shake256Engine(int n, int w, int d, int a, int k, int h)
{
super(n, w, d, a, k, h);
this.treeDigest = new SHAKEDigest(256);
this.maskDigest = new SHAKEDigest(256);
}
void init(byte[] pkSeed)
{
}
byte[] F(byte[] pkSeed, ADRS adrs, byte[] m1)
{
byte[] mTheta = m1;
byte[] rv = new byte[N];
treeDigest.update(pkSeed, 0, pkSeed.length);
treeDigest.update(adrs.value, 0, adrs.value.length);
treeDigest.update(mTheta, 0, mTheta.length);
treeDigest.doFinal(rv, 0, rv.length);
return rv;
}
byte[] H(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2)
{
byte[] rv = new byte[N];
treeDigest.update(pkSeed, 0, pkSeed.length);
treeDigest.update(adrs.value, 0, adrs.value.length);
treeDigest.update(m1, 0, m1.length);
treeDigest.update(m2, 0, m2.length);
treeDigest.doFinal(rv, 0, rv.length);
return rv;
}
IndexedDigest H_msg(byte[] R, byte[] pkSeed, byte[] pkRoot, byte[] msgPrefix, byte[] msg)
{
int forsMsgBytes = ((A * K) + 7) / 8;
int leafBits = H / D;
int treeBits = H - leafBits;
int leafBytes = (leafBits + 7) / 8;
int treeBytes = (treeBits + 7) / 8;
int m = forsMsgBytes + leafBytes + treeBytes;
byte[] out = new byte[m];
treeDigest.update(R, 0, R.length);
treeDigest.update(pkSeed, 0, pkSeed.length);
treeDigest.update(pkRoot, 0, pkRoot.length);
if (msgPrefix != null)
{
treeDigest.update(msgPrefix, 0, msgPrefix.length);
}
treeDigest.update(msg, 0, msg.length);
treeDigest.doFinal(out, 0, out.length);
// tree index
// currently, only indexes up to 64 bits are supported
byte[] treeIndexBuf = new byte[8];
System.arraycopy(out, forsMsgBytes, treeIndexBuf, 8 - treeBytes, treeBytes);
long treeIndex = Pack.bigEndianToLong(treeIndexBuf, 0);
treeIndex &= (~0L) >>> (64 - treeBits);
byte[] leafIndexBuf = new byte[4];
System.arraycopy(out, forsMsgBytes + treeBytes, leafIndexBuf, 4 - leafBytes, leafBytes);
int leafIndex = Pack.bigEndianToInt(leafIndexBuf, 0);
leafIndex &= (~0) >>> (32 - leafBits);
return new IndexedDigest(treeIndex, leafIndex, Arrays.copyOfRange(out, 0, forsMsgBytes));
}
byte[] T_l(byte[] pkSeed, ADRS adrs, byte[] m)
{
byte[] mTheta = m;
byte[] rv = new byte[N];
treeDigest.update(pkSeed, 0, pkSeed.length);
treeDigest.update(adrs.value, 0, adrs.value.length);
treeDigest.update(mTheta, 0, mTheta.length);
treeDigest.doFinal(rv, 0, rv.length);
return rv;
}
byte[] PRF(byte[] pkSeed, byte[] skSeed, ADRS adrs)
{
treeDigest.update(pkSeed, 0, pkSeed.length);
treeDigest.update(adrs.value, 0, adrs.value.length);
treeDigest.update(skSeed, 0, skSeed.length);
byte[] prf = new byte[N];
treeDigest.doFinal(prf, 0, N);
return prf;
}
public byte[] PRF_msg(byte[] prf, byte[] randomiser, byte[] msgPrefix, byte[] msg)
{
treeDigest.update(prf, 0, prf.length);
treeDigest.update(randomiser, 0, randomiser.length);
if (msgPrefix != null)
{
treeDigest.update(msgPrefix, 0, msgPrefix.length);
}
treeDigest.update(msg, 0, msg.length);
byte[] out = new byte[N];
treeDigest.doFinal(out, 0, out.length);
return out;
}
protected byte[] bitmask(byte[] pkSeed, ADRS adrs, byte[] m)
{
byte[] mask = new byte[m.length];
maskDigest.update(pkSeed, 0, pkSeed.length);
maskDigest.update(adrs.value, 0, adrs.value.length);
maskDigest.doFinal(mask, 0, mask.length);
Bytes.xorTo(m.length, m, mask);
return mask;
}
protected byte[] bitmask(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2)
{
byte[] mask = new byte[m1.length + m2.length];
maskDigest.update(pkSeed, 0, pkSeed.length);
maskDigest.update(adrs.value, 0, adrs.value.length);
maskDigest.doFinal(mask, 0, mask.length);
Bytes.xorTo(m1.length, m1, mask);
Bytes.xorTo(m2.length, m2, 0, mask, m1.length);
return mask;
}
}
}

View File

@ -0,0 +1,19 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
interface SLHDSAEngineProvider
{
int getN();
SLHDSAEngine get();
}

View File

@ -0,0 +1,33 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import java.security.SecureRandom;
import org.bouncycastle.crypto.KeyGenerationParameters;
@SuppressWarnings("all")
public class SLHDSAKeyGenerationParameters
extends KeyGenerationParameters
{
private final SLHDSAParameters parameters;
public SLHDSAKeyGenerationParameters(SecureRandom random, SLHDSAParameters parameters)
{
super(random, -1);
this.parameters = parameters;
}
SLHDSAParameters getParameters()
{
return parameters;
}
}

View File

@ -0,0 +1,70 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import java.security.SecureRandom;
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
import org.bouncycastle.crypto.AsymmetricCipherKeyPairGenerator;
import org.bouncycastle.crypto.KeyGenerationParameters;
@SuppressWarnings("all")
public class SLHDSAKeyPairGenerator
implements AsymmetricCipherKeyPairGenerator
{
private SecureRandom random;
private SLHDSAParameters parameters;
public void init(KeyGenerationParameters param)
{
random = param.getRandom();
parameters = ((SLHDSAKeyGenerationParameters)param).getParameters();
}
public AsymmetricCipherKeyPair internalGenerateKeyPair(byte[] skSeed, byte[] skPrf, byte[] pkSeed)
{
return implGenerateKeyPair(parameters.getEngine(), skSeed, skPrf, pkSeed);
}
public AsymmetricCipherKeyPair generateKeyPair()
{
SLHDSAEngine engine = parameters.getEngine();
byte[] skSeed = sec_rand(engine.N);
byte[] skPrf = sec_rand(engine.N);
byte[] pkSeed = sec_rand(engine.N);
return implGenerateKeyPair(engine, skSeed, skPrf, pkSeed);
}
private AsymmetricCipherKeyPair implGenerateKeyPair(SLHDSAEngine engine, byte[] skSeed, byte[] skPrf, byte[] pkSeed)
{
SK sk = new SK(skSeed, skPrf);
engine.init(pkSeed);
// TODO
PK pk = new PK(pkSeed, new HT(engine, sk.seed, pkSeed).htPubKey);
return new AsymmetricCipherKeyPair(
new SLHDSAPublicKeyParameters(parameters, pk),
new SLHDSAPrivateKeyParameters(parameters, sk, pk));
}
private byte[] sec_rand(int n)
{
byte[] rv = new byte[n];
random.nextBytes(rv);
return rv;
}
}

View File

@ -0,0 +1,31 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
@SuppressWarnings("all")
public class SLHDSAKeyParameters
extends AsymmetricKeyParameter
{
private final SLHDSAParameters parameters;
protected SLHDSAKeyParameters(boolean isPrivate, SLHDSAParameters parameters)
{
super(isPrivate);
this.parameters = parameters;
}
public SLHDSAParameters getParameters()
{
return parameters;
}
}

View File

@ -0,0 +1,186 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
@SuppressWarnings("all")
public class SLHDSAParameters
{
public static final int TYPE_PURE = 0;
public static final int TYPE_SHA2_256 = 1;
public static final int TYPE_SHA2_512 = 2;
public static final int TYPE_SHAKE128 = 3;
public static final int TYPE_SHAKE256 = 4;
// "Pure" SLH-DSA Parameters
// SHA-2
public static final SLHDSAParameters sha2_128f = new SLHDSAParameters(
"sha2-128f", new Sha2EngineProvider(16, 16, 22, 6, 33, 66), TYPE_PURE);
public static final SLHDSAParameters sha2_128s = new SLHDSAParameters(
"sha2-128s", new Sha2EngineProvider(16, 16, 7, 12, 14, 63), TYPE_PURE);
public static final SLHDSAParameters sha2_192f = new SLHDSAParameters(
"sha2-192f", new Sha2EngineProvider(24, 16, 22, 8, 33, 66), TYPE_PURE);
public static final SLHDSAParameters sha2_192s = new SLHDSAParameters(
"sha2-192s", new Sha2EngineProvider(24, 16, 7, 14, 17, 63), TYPE_PURE);
public static final SLHDSAParameters sha2_256f = new SLHDSAParameters(
"sha2-256f", new Sha2EngineProvider(32, 16, 17, 9, 35, 68), TYPE_PURE);
public static final SLHDSAParameters sha2_256s = new SLHDSAParameters(
"sha2-256s", new Sha2EngineProvider(32, 16, 8, 14, 22, 64), TYPE_PURE);
// SHAKE-256.
public static final SLHDSAParameters shake_128f = new SLHDSAParameters(
"shake-128f", new Shake256EngineProvider(16, 16, 22, 6, 33, 66), TYPE_PURE);
public static final SLHDSAParameters shake_128s = new SLHDSAParameters(
"shake-128s", new Shake256EngineProvider(16, 16, 7, 12, 14, 63), TYPE_PURE);
public static final SLHDSAParameters shake_192f = new SLHDSAParameters(
"shake-192f", new Shake256EngineProvider(24, 16, 22, 8, 33, 66), TYPE_PURE);
public static final SLHDSAParameters shake_192s = new SLHDSAParameters(
"shake-192s", new Shake256EngineProvider(24, 16, 7, 14, 17, 63), TYPE_PURE);
public static final SLHDSAParameters shake_256f = new SLHDSAParameters(
"shake-256f", new Shake256EngineProvider(32, 16, 17, 9, 35, 68), TYPE_PURE);
public static final SLHDSAParameters shake_256s = new SLHDSAParameters(
"shake-256s", new Shake256EngineProvider(32, 16, 8, 14, 22, 64), TYPE_PURE);
// "Pre-hash" SLH-DSA Parameters
// SHA-2
public static final SLHDSAParameters sha2_128f_with_sha256 = new SLHDSAParameters(
"sha2-128f-with-sha256", new Sha2EngineProvider(16, 16, 22, 6, 33, 66), TYPE_SHA2_256);
public static final SLHDSAParameters sha2_128s_with_sha256 = new SLHDSAParameters(
"sha2-128s-with-sha256", new Sha2EngineProvider(16, 16, 7, 12, 14, 63), TYPE_SHA2_256);
public static final SLHDSAParameters sha2_192f_with_sha512 = new SLHDSAParameters(
"sha2-192f-with-sha512", new Sha2EngineProvider(24, 16, 22, 8, 33, 66), TYPE_SHA2_512);
public static final SLHDSAParameters sha2_192s_with_sha512 = new SLHDSAParameters(
"sha2-192s-with-sha512", new Sha2EngineProvider(24, 16, 7, 14, 17, 63), TYPE_SHA2_512);
public static final SLHDSAParameters sha2_256f_with_sha512 = new SLHDSAParameters(
"sha2-256f-with-sha512", new Sha2EngineProvider(32, 16, 17, 9, 35, 68), TYPE_SHA2_512);
public static final SLHDSAParameters sha2_256s_with_sha512 = new SLHDSAParameters(
"sha2-256s-with-sha512", new Sha2EngineProvider(32, 16, 8, 14, 22, 64), TYPE_SHA2_512);
// SHAKE-256.
public static final SLHDSAParameters shake_128f_with_shake128 = new SLHDSAParameters(
"shake-128f-with-shake128", new Shake256EngineProvider(16, 16, 22, 6, 33, 66), TYPE_SHAKE128);
public static final SLHDSAParameters shake_128s_with_shake128 = new SLHDSAParameters(
"shake-128s-with-shake128", new Shake256EngineProvider(16, 16, 7, 12, 14, 63), TYPE_SHAKE128);
public static final SLHDSAParameters shake_192f_with_shake256 = new SLHDSAParameters(
"shake-192f-with-shake256", new Shake256EngineProvider(24, 16, 22, 8, 33, 66), TYPE_SHAKE256);
public static final SLHDSAParameters shake_192s_with_shake256 = new SLHDSAParameters(
"shake-192s-with-shake256", new Shake256EngineProvider(24, 16, 7, 14, 17, 63), TYPE_SHAKE256);
public static final SLHDSAParameters shake_256f_with_shake256 = new SLHDSAParameters(
"shake-256f-with-shake256", new Shake256EngineProvider(32, 16, 17, 9, 35, 68), TYPE_SHAKE256);
public static final SLHDSAParameters shake_256s_with_shake256 = new SLHDSAParameters(
"shake-256s-with-shake256", new Shake256EngineProvider(32, 16, 8, 14, 22, 64), TYPE_SHAKE256);
private final String name;
private final SLHDSAEngineProvider engineProvider;
private final int preHashDigest;
private SLHDSAParameters(String name, SLHDSAEngineProvider engineProvider, int preHashDigest)
{
this.name = name;
this.engineProvider = engineProvider;
this.preHashDigest = preHashDigest;
}
public String getName()
{
return name;
}
public int getType()
{
return preHashDigest;
}
public int getN()
{
return engineProvider.getN();
}
SLHDSAEngine getEngine()
{
return engineProvider.get();
}
public boolean isPreHash()
{
return preHashDigest != TYPE_PURE;
}
private static class Sha2EngineProvider
implements SLHDSAEngineProvider
{
private final int n;
private final int w;
private final int d;
private final int a;
private final int k;
private final int h;
public Sha2EngineProvider(int n, int w, int d, int a, int k, int h)
{
this.n = n;
this.w = w;
this.d = d;
this.a = a;
this.k = k;
this.h = h;
}
public int getN()
{
return n;
}
public SLHDSAEngine get()
{
return new SLHDSAEngine.Sha2Engine(n, w, d, a, k, h);
}
}
private static class Shake256EngineProvider
implements SLHDSAEngineProvider
{
private final int n;
private final int w;
private final int d;
private final int a;
private final int k;
private final int h;
public Shake256EngineProvider(int n, int w, int d, int a, int k, int h)
{
this.n = n;
this.w = w;
this.d = d;
this.a = a;
this.k = k;
this.h = h;
}
public int getN()
{
return n;
}
public SLHDSAEngine get()
{
return new SLHDSAEngine.Shake256Engine(n, w, d, a, k, h);
}
}
}

View File

@ -0,0 +1,80 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import org.bouncycastle.util.Arrays;
@SuppressWarnings("all")
public class SLHDSAPrivateKeyParameters
extends SLHDSAKeyParameters
{
final SK sk;
final PK pk;
public SLHDSAPrivateKeyParameters(SLHDSAParameters parameters, byte[] skpkEncoded)
{
super(true, parameters);
int n = parameters.getN();
if (skpkEncoded.length != 4 * n)
{
throw new IllegalArgumentException("private key encoding does not match parameters");
}
this.sk = new SK(Arrays.copyOfRange(skpkEncoded, 0, n), Arrays.copyOfRange(skpkEncoded, n, 2 * n));
this.pk = new PK(Arrays.copyOfRange(skpkEncoded, 2 * n, 3 * n), Arrays.copyOfRange(skpkEncoded, 3 * n, 4 * n));
}
public SLHDSAPrivateKeyParameters(SLHDSAParameters parameters, byte[] skSeed, byte[] prf, byte[] pkSeed, byte[] pkRoot)
{
super(true, parameters);
this.sk = new SK(skSeed, prf);
this.pk = new PK(pkSeed, pkRoot);
}
SLHDSAPrivateKeyParameters(SLHDSAParameters parameters, SK sk, PK pk)
{
super(true, parameters);
this.sk = sk;
this.pk = pk;
}
public byte[] getSeed()
{
return Arrays.clone(sk.seed);
}
public byte[] getPrf()
{
return Arrays.clone(sk.prf);
}
public byte[] getPublicSeed()
{
return Arrays.clone(pk.seed);
}
public byte[] getRoot()
{
return Arrays.clone(pk.root);
}
public byte[] getPublicKey()
{
return Arrays.concatenate(pk.seed, pk.root);
}
public byte[] getEncoded()
{
return Arrays.concatenate(new byte[][]{ sk.seed, sk.prf, pk.seed, pk.root });
}
public byte[] getEncodedPublicKey()
{
return Arrays.concatenate(pk.seed, pk.root);
}
}

View File

@ -0,0 +1,52 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import org.bouncycastle.util.Arrays;
@SuppressWarnings("all")
public class SLHDSAPublicKeyParameters
extends SLHDSAKeyParameters
{
private final PK pk;
public SLHDSAPublicKeyParameters(SLHDSAParameters parameters, byte[] pkValues)
{
super(false, parameters);
int n = parameters.getN();
if (pkValues.length != 2 * n)
{
throw new IllegalArgumentException("public key encoding does not match parameters");
}
this.pk = new PK(Arrays.copyOfRange(pkValues, 0, n), Arrays.copyOfRange(pkValues, n, 2 * n));
}
SLHDSAPublicKeyParameters(SLHDSAParameters parameters, PK pk)
{
super(false, parameters);
this.pk = pk;
}
public byte[] getSeed()
{
return Arrays.clone(pk.seed);
}
public byte[] getRoot()
{
return Arrays.clone(pk.root);
}
public byte[] getEncoded()
{
return Arrays.concatenate(pk.seed, pk.root);
}
}

View File

@ -0,0 +1,236 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import java.security.SecureRandom;
import org.bouncycastle.crypto.CipherParameters;
import org.bouncycastle.crypto.params.ParametersWithContext;
import org.bouncycastle.crypto.params.ParametersWithRandom;
import org.bouncycastle.pqc.crypto.MessageSigner;
import org.bouncycastle.util.Arrays;
/**
* SLH-DA signer.
* <p>
* This version is based on the 3rd submission with deference to the updated reference
* implementation on github as at November 9th 2021. This version includes the changes
* for the countermeasure for the long-message second preimage attack - see
* "https://github.com/sphincs/sphincsplus/commit/61cd2695c6f984b4f4d6ed675378ed9a486cbede"
* for further details.
* </p>
*/
@SuppressWarnings("all")
public class SLHDSASigner
implements MessageSigner
{
private static final byte[] DEFAULT_PREFIX = new byte[]{ 0, 0 };
private byte[] msgPrefix;
private SLHDSAPublicKeyParameters pubKey;
private SLHDSAPrivateKeyParameters privKey;
private SecureRandom random;
/**
* Base constructor.
*/
public SLHDSASigner()
{
}
public void init(boolean forSigning, CipherParameters param)
{
if (param instanceof ParametersWithContext)
{
ParametersWithContext withContext = (ParametersWithContext)param;
param = withContext.getParameters();
int ctxLength = withContext.getContextLength();
if (ctxLength > 255)
{
throw new IllegalArgumentException("context too long");
}
msgPrefix = new byte[2 + ctxLength];
msgPrefix[0] = 0;
msgPrefix[1] = (byte)ctxLength;
withContext.copyContextTo(msgPrefix, 2, ctxLength);
}
else
{
msgPrefix = DEFAULT_PREFIX;
}
SLHDSAParameters parameters;
if (forSigning)
{
pubKey = null;
if (param instanceof ParametersWithRandom)
{
ParametersWithRandom withRandom = (ParametersWithRandom)param;
privKey = (SLHDSAPrivateKeyParameters)withRandom.getParameters();
random = withRandom.getRandom();
}
else
{
privKey = (SLHDSAPrivateKeyParameters)param;
random = null;
}
parameters = privKey.getParameters();
}
else
{
pubKey = (SLHDSAPublicKeyParameters)param;
privKey = null;
random = null;
parameters = pubKey.getParameters();
}
if (parameters.isPreHash())
{
throw new IllegalArgumentException("\"pure\" slh-dsa must use non pre-hash parameters");
}
}
public byte[] generateSignature(byte[] message)
{
// TODO Redundant with the engine created in internalGenerateSignature
SLHDSAEngine engine = privKey.getParameters().getEngine();
engine.init(privKey.pk.seed);
// generate randomizer
byte[] optRand = new byte[engine.N];
if (random != null)
{
random.nextBytes(optRand);
}
else
{
System.arraycopy(privKey.pk.seed, 0, optRand, 0, optRand.length);
}
return internalGenerateSignature(privKey, msgPrefix, message, optRand);
}
// Equivalent to slh_verify_internal from specs
public boolean verifySignature(byte[] message, byte[] signature)
{
return internalVerifySignature(pubKey, msgPrefix, message, signature);
}
protected boolean internalVerifySignature(byte[] message, byte[] signature)
{
return internalVerifySignature(pubKey, null, message, signature);
}
private static boolean internalVerifySignature(SLHDSAPublicKeyParameters pubKey, byte[] msgPrefix, byte[] msg,
byte[] signature)
{
// TODO Check init via pubKey != null
//# Input: Message M, signature SIG, public key PK
//# Output: Boolean
// init
SLHDSAEngine engine = pubKey.getParameters().getEngine();
engine.init(pubKey.getSeed());
ADRS adrs = new ADRS();
if (((1 + engine.K * (1 + engine.A) + engine.H + engine.D * engine.WOTS_LEN) * engine.N) != signature.length)
{
return false;
}
SIG sig = new SIG(engine.N, engine.K, engine.A, engine.D, engine.H_PRIME, engine.WOTS_LEN, signature);
byte[] R = sig.getR();
SIG_FORS[] sig_fors = sig.getSIG_FORS();
SIG_XMSS[] SIG_HT = sig.getSIG_HT();
// compute message digest and index
IndexedDigest idxDigest = engine.H_msg(R, pubKey.getSeed(), pubKey.getRoot(), msgPrefix, msg);
byte[] mHash = idxDigest.digest;
long idx_tree = idxDigest.idx_tree;
int idx_leaf = idxDigest.idx_leaf;
// compute FORS public key
adrs.setTypeAndClear(ADRS.FORS_TREE);
adrs.setLayerAddress(0);
adrs.setTreeAddress(idx_tree);
adrs.setKeyPairAddress(idx_leaf);
byte[] PK_FORS = new Fors(engine).pkFromSig(sig_fors, mHash, pubKey.getSeed(), adrs);
// verify HT signature
adrs.setTypeAndClear(ADRS.TREE);
adrs.setLayerAddress(0);
adrs.setTreeAddress(idx_tree);
adrs.setKeyPairAddress(idx_leaf);
HT ht = new HT(engine, null, pubKey.getSeed());
return ht.verify(PK_FORS, SIG_HT, pubKey.getSeed(), idx_tree, idx_leaf, pubKey.getRoot());
}
protected byte[] internalGenerateSignature(byte[] message, byte[] optRand)
{
return internalGenerateSignature(privKey, null, message, optRand);
}
private static byte[] internalGenerateSignature(SLHDSAPrivateKeyParameters privKey, byte[] msgPrefix, byte[] msg,
byte[] optRand)
{
// TODO Check init via privKey != null
SLHDSAEngine engine = privKey.getParameters().getEngine();
engine.init(privKey.pk.seed);
Fors fors = new Fors(engine);
byte[] R = engine.PRF_msg(privKey.sk.prf, optRand, msgPrefix, msg);
IndexedDigest idxDigest = engine.H_msg(R, privKey.pk.seed, privKey.pk.root, msgPrefix, msg);
byte[] mHash = idxDigest.digest;
long idx_tree = idxDigest.idx_tree;
int idx_leaf = idxDigest.idx_leaf;
// FORS sign
ADRS adrs = new ADRS();
adrs.setTypeAndClear(ADRS.FORS_TREE);
adrs.setTreeAddress(idx_tree);
adrs.setKeyPairAddress(idx_leaf);
SIG_FORS[] sig_fors = fors.sign(mHash, privKey.sk.seed, privKey.pk.seed, adrs);
// get FORS public key - spec shows M?
adrs = new ADRS();
adrs.setTypeAndClear(ADRS.FORS_TREE);
adrs.setTreeAddress(idx_tree);
adrs.setKeyPairAddress(idx_leaf);
byte[] PK_FORS = fors.pkFromSig(sig_fors, mHash, privKey.pk.seed, adrs);
// sign FORS public key with HT
ADRS treeAdrs = new ADRS();
treeAdrs.setTypeAndClear(ADRS.TREE);
HT ht = new HT(engine, privKey.getSeed(), privKey.getPublicSeed());
byte[] SIG_HT = ht.sign(PK_FORS, idx_tree, idx_leaf);
byte[][] sigComponents = new byte[sig_fors.length + 2][];
sigComponents[0] = R;
for (int i = 0; i != sig_fors.length; i++)
{
sigComponents[1 + i] = Arrays.concatenate(sig_fors[i].sk, Arrays.concatenate(sig_fors[i].authPath));
}
sigComponents[sigComponents.length - 1] = SIG_HT;
return Arrays.concatenate(sigComponents);
}
}

View File

@ -0,0 +1,177 @@
/*
* Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence,
* Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),
* regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337).
* The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI
* intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature
* cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are
* FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions.
* DO NOT EDIT BY HAND: regenerate with the script above.
*/
package org.hyperledger.besu.consensus.common.bft.slhdsa;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.Pack;
@SuppressWarnings("all")
class WotsPlus
{
private final SLHDSAEngine engine;
private final int w;
WotsPlus(SLHDSAEngine engine)
{
this.engine = engine;
this.w = this.engine.WOTS_W;
}
byte[] pkGen(byte[] skSeed, byte[] pkSeed, ADRS paramAdrs)
{
ADRS wotspkADRS = new ADRS(paramAdrs); // copy address to create OTS public key address
byte[][] tmp = new byte[engine.WOTS_LEN][];
for (int i = 0; i < engine.WOTS_LEN; i++)
{
ADRS adrs = new ADRS(paramAdrs);
adrs.setTypeAndClear(ADRS.WOTS_PRF);
adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress());
adrs.setChainAddress(i);
adrs.setHashAddress(0);
byte[] sk = engine.PRF(pkSeed, skSeed, adrs);
adrs.setTypeAndClear(ADRS.WOTS_HASH);
adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress());
adrs.setChainAddress(i);
adrs.setHashAddress(0);
tmp[i] = chain(sk, 0, w - 1, pkSeed, adrs);
}
wotspkADRS.setTypeAndClear(ADRS.WOTS_PK);
wotspkADRS.setKeyPairAddress(paramAdrs.getKeyPairAddress());
return engine.T_l(pkSeed, wotspkADRS, Arrays.concatenate(tmp));
}
// #Input: Input string X, start index i, number of steps s, public seed PK.seed, address ADRS
// #Output: value of F iterated s times on X
byte[] chain(byte[] X, int i, int s, byte[] pkSeed, ADRS adrs)
{
if (s == 0)
{
return Arrays.clone(X);
}
if ((i + s) > (this.w - 1))
{
return null;
}
byte[] result = X;
for (int j = 0; j < s; ++j)
{
adrs.setHashAddress(i + j);
result = engine.F(pkSeed, adrs, result);
}
return result;
}
// #Input: Message M, secret seed SK.seed, public seed PK.seed, address ADRS
// #Output: WOTS+ signature sig
public byte[] sign(byte[] M, byte[] skSeed, byte[] pkSeed, ADRS paramAdrs)
{
ADRS adrs = new ADRS(paramAdrs);
int[] msg = new int[engine.WOTS_LEN];
// convert message to base w
base_w(M, 0, w, msg, 0, engine.WOTS_LEN1);
// compute checksum
int csum = 0;
for (int i = 0; i < engine.WOTS_LEN1; i++)
{
csum += w - 1 - msg[i];
}
// convert csum to base w
if ((engine.WOTS_LOGW % 8) != 0)
{
csum = csum << (8 - ((engine.WOTS_LEN2 * engine.WOTS_LOGW) % 8));
}
int len_2_bytes = (engine.WOTS_LEN2 * engine.WOTS_LOGW + 7) / 8;
byte[] csum_bytes = Pack.intToBigEndian(csum);
base_w(csum_bytes, 4 - len_2_bytes, w, msg, engine.WOTS_LEN1, engine.WOTS_LEN2);
byte[][] sig = new byte[engine.WOTS_LEN][];
for (int i = 0; i < engine.WOTS_LEN; i++)
{
adrs.setTypeAndClear(ADRS.WOTS_PRF);
adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress());
adrs.setChainAddress(i);
adrs.setHashAddress(0);
byte[] sk = engine.PRF(pkSeed, skSeed, adrs);
adrs.setTypeAndClear(ADRS.WOTS_HASH);
adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress());
adrs.setChainAddress(i);
adrs.setHashAddress(0);
sig[i] = chain(sk, 0, msg[i], pkSeed, adrs);
}
return Arrays.concatenate(sig);
}
//
// Input: len_X-byte string X, int w, output length out_len
// Output: out_len int array basew
void base_w(byte[] X, int XOff, int w, int[] output, int outOff, int outLen)
{
int total = 0;
int bits = 0;
for (int consumed = 0; consumed < outLen; consumed++)
{
if (bits == 0)
{
total = X[XOff++];
bits += 8;
}
bits -= engine.WOTS_LOGW;
output[outOff++] = ((total >>> bits) & (w - 1));
}
}
public byte[] pkFromSig(byte[] sig, byte[] M, byte[] pkSeed, ADRS adrs)
{
ADRS wotspkADRS = new ADRS(adrs);
int[] msg = new int[engine.WOTS_LEN];
// convert message to base w
base_w(M, 0, w, msg, 0, engine.WOTS_LEN1);
// compute checksum
int csum = 0;
for (int i = 0; i < engine.WOTS_LEN1; i++ )
{
csum += w - 1 - msg[i];
}
// convert csum to base w
csum = csum << (8 - ((engine.WOTS_LEN2 * engine.WOTS_LOGW) % 8));
int len_2_bytes = (engine.WOTS_LEN2 * engine.WOTS_LOGW + 7) / 8;
byte[] csum_bytes = Pack.intToBigEndian(csum);
base_w(csum_bytes, 4 - len_2_bytes, w, msg, engine.WOTS_LEN1, engine.WOTS_LEN2);
byte[] sigI = new byte[engine.N];
byte[][] tmp = new byte[engine.WOTS_LEN][];
for (int i = 0; i < engine.WOTS_LEN; i++ )
{
adrs.setChainAddress(i);
System.arraycopy(sig, i * engine.N, sigI, 0, engine.N);
tmp[i] = chain(sigI, msg[i], w - 1 - msg[i], pkSeed, adrs);
}
wotspkADRS.setTypeAndClear(ADRS.WOTS_PK);
wotspkADRS.setKeyPairAddress(adrs.getKeyPairAddress());
return engine.T_l(pkSeed, wotspkADRS, Arrays.concatenate(tmp));
}
}

View File

@ -0,0 +1,106 @@
/*
* Copyright contributors to Aere Network.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in
* compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is
* distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing permissions and limitations under the
* License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.common.bft;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Map;
import java.util.OptionalInt;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* D-337 (2026-09-04): the extra wait before a proposal at an ANCHOR height, and only there.
*
* <p>The measured defect: with the hybrid certificate live on chain 2800 the proposal timer fired
* before the sixth SLH-DSA seal of the parent arrived, the producer refused, and eleven of twelve
* anchors paid the four-second round-change timeout. The fix is local timing, so the pinned
* properties are exactly two: the value is refused when it is nonsense (a bad delay must stop the
* node at start, not surprise the state machine), and it is added at anchor heights ONLY, so an
* ordinary block is never slowed by a millisecond.
*/
class AereAnchorProposalDelayTest {
@BeforeEach
@AfterEach
void clean() {
System.clearProperty(AereAnchorProposalDelay.PROPERTY);
AereAnchorProposalDelay.forgetForTesting();
PqAnchorProducer.useConfigForTesting(null);
}
@Test
void unsetMeansExactlyTodaysBehaviour() {
assertThat(AereAnchorProposalDelay.configuredMillis()).isZero();
assertThat(AereAnchorProposalDelay.millisFor(13_014_016L)).isZero();
}
@Test
void nonsenseIsRefused() {
assertThatThrownBy(() -> AereAnchorProposalDelay.parse("later"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("AERE-PQC-ANCHOR-CONF-05");
assertThatThrownBy(() -> AereAnchorProposalDelay.parse("-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("outside");
assertThatThrownBy(() -> AereAnchorProposalDelay.parse("5001"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("outside");
assertThat(AereAnchorProposalDelay.parse("1200")).isEqualTo(1200L);
assertThat(AereAnchorProposalDelay.parse(null)).isZero();
assertThat(AereAnchorProposalDelay.parse(" ")).isZero();
}
@Test
void theWaitAppliesAtAnchorHeightsAndNowhereElse() {
// the live shape of chain 2800: anchors from 13,014,000, every 32nd block
final PqAnchorConfig cfg =
new PqAnchorConfig(
2800L,
13_014_000L,
Map.of(13_014_000L, 6),
OptionalInt.empty(),
false,
OptionalInt.empty(),
OptionalInt.of(32),
13_014_000L);
PqAnchorProducer.useConfigForTesting(cfg);
System.setProperty(AereAnchorProposalDelay.PROPERTY, "1200");
AereAnchorProposalDelay.forgetForTesting();
assertThat(AereAnchorProposalDelay.configuredMillis()).isEqualTo(1200L);
assertThat(cfg.anchorAppliesAt(13_014_032L)).isTrue();
assertThat(AereAnchorProposalDelay.millisFor(13_014_032L)).isEqualTo(1200L);
// the parent of an anchor, and an ordinary block: not a millisecond
assertThat(AereAnchorProposalDelay.millisFor(13_014_031L)).isZero();
assertThat(AereAnchorProposalDelay.millisFor(13_014_033L)).isZero();
// below the activation height nothing is an anchor
assertThat(AereAnchorProposalDelay.millisFor(13_013_968L)).isZero();
}
@Test
void aChainWithoutAnAnchorNeverWaits() {
PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(2800L));
System.setProperty(AereAnchorProposalDelay.PROPERTY, "1200");
AereAnchorProposalDelay.forgetForTesting();
assertThat(AereAnchorProposalDelay.millisFor(13_014_032L)).isZero();
assertThat(AereAnchorProposalDelay.millisFor(1L)).isZero();
}
}

View File

@ -244,7 +244,9 @@ public class PqAnchorConfigTest {
// set, with no anchor height, is no longer "a node with no anchor configuration"; it becomes
// one with an incomplete configuration, and it refuses to start. A cap or an interval with no
// anchor means nothing, and it is better for it to shout than to keep quiet.
assertThat(reader.accesses).hasSize(14);
// 16 since 2026-09-03: the v2 activation height (property + environment) joined the names
// 18 since 2026-09-04: the interval SCHEDULE (D-336, disk) joined them, between the interval and the v2 height
assertThat(reader.accesses).hasSize(18);
assertThat(reader.accesses)
.containsExactly(
"property:" + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
@ -261,6 +263,12 @@ public class PqAnchorConfigTest {
"environment:" + PqAnchorConfig.ENV_MAX_SEALS,
"property:" + PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL,
"environment:" + PqAnchorConfig.ENV_ANCHOR_INTERVAL,
"property:" + PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL_SCHEDULE,
"environment:" + PqAnchorConfig.ENV_ANCHOR_INTERVAL_SCHEDULE,
// added 2026-09-03 with the scheme-tagged (v2) certificate: its activation height sits
// between the interval and the disarm, as in ANCHOR_NAMES
"property:" + PqAnchorConfig.PROPERTY_ANCHOR_V2_BLOCK,
"environment:" + PqAnchorConfig.ENV_ANCHOR_V2_BLOCK,
"property:" + PqAnchorConfig.PROPERTY_DISABLE,
"environment:" + PqAnchorConfig.ENV_DISABLE);
}
@ -694,4 +702,53 @@ public class PqAnchorConfigTest {
assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
.hasMessageContaining("SOURCE environment variable " + PqAnchorConfig.ENV_ANCHOR_BLOCK);
}
// ---- D-336: the interval schedule -------------------------------------------------------------
private static PqAnchorConfig every32() {
return new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), false)
.withAnchorInterval(OptionalInt.of(32));
}
@Test
public void aScheduledIntervalKeepsTheOldGridBelowTheHeightAndThinsItAbove() {
// 1384 = 1000 + 3 * 128: on the new grid AND an old-grid anchor (384 % 32 == 0)
final PqAnchorConfig c = every32().withAnchorIntervalSchedule(java.util.Map.of(1384L, 128));
assertThat(c.intervalAt(1383L)).isEqualTo(32);
assertThat(c.intervalAt(1384L)).isEqualTo(128);
assertThat(c.isAnchorHeight(1032L)).isTrue();
assertThat(c.isAnchorHeight(1048L)).isFalse();
assertThat(c.isAnchorHeight(1352L)).isTrue(); // last old-grid anchor before the change
assertThat(c.isAnchorHeight(1384L)).isTrue(); // the activation height is itself an anchor
assertThat(c.isAnchorHeight(1416L)).isFalse(); // 1384 + 32: no longer an anchor
assertThat(c.isAnchorHeight(1512L)).isTrue(); // 1384 + 128
// every anchor of the new regime was an anchor of the old one
for (long h = 1384L; h < 1384L + 128L * 20; h++) {
if (c.isAnchorHeight(h)) {
assertThat(every32().isAnchorHeight(h)).isTrue();
}
}
}
@Test
public void aScheduleHeightOffTheNewGridIsRefused() {
assertThatThrownBy(() -> every32().withAnchorIntervalSchedule(java.util.Map.of(1400L, 128)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not on the new grid");
}
@Test
public void anIntervalThatIsNotAMultipleOfTheBaseIsRefused() {
assertThatThrownBy(() -> every32().withAnchorIntervalSchedule(java.util.Map.of(1400L, 100)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not a positive multiple");
}
@Test
public void aScheduleWithoutABaseIntervalIsRefused() {
final PqAnchorConfig everyBlock = new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), false);
assertThatThrownBy(() -> everyBlock.withAnchorIntervalSchedule(java.util.Map.of(1384L, 128)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("cannot create one");
}
}

View File

@ -0,0 +1,326 @@
/*
* Copyright contributors to Aere Network.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in
* compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is
* distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing permissions and limitations under the
* License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.common.bft;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
import org.hyperledger.besu.crypto.SecureRandomProvider;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.ProtocolContext;
import org.hyperledger.besu.ethereum.core.BlockHeader;
import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import org.apache.tuweni.bytes.Bytes;
import org.apache.tuweni.bytes.Bytes32;
import org.bouncycastle.crypto.digests.KeccakDigest;
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.quality.Strictness;
/**
* AERE ANCHOR V2 (2026-09-03): the PROPOSER writes the SCHEME-TAGGED certificate from the v2 height.
*
* <p>Same harness as {@link PqAnchorProducerCostTest} (seven Falcon probe keys anchored in a genesis
* registry, the seal cache loaded by hand, a mocked validator provider), plus a hybrid registry with
* seven SLH-DSA-SHA2-128s probe keys and the scheme schedule, loaded through the REAL system
* configuration path so the producer finds them exactly where a node would.
*
* <p>Proven, each with its pair: at a v2 height the certificate is [Falcon x K + SLH-DSA x K] under the
* v2 digest and the v1 list is EMPTY; below the v2 height nothing changes (v1 list, v1 digest); with
* no SLH-DSA seal heard the proposer REFUSES rather than writing a certificate short of a scheme; an
* SLH-DSA seal from an index without a Falcon seal is not counted; a Falcon-only schedule yields a v2
* certificate that is Falcon-only.
*/
class PqAnchorV2ProducerTest {
private static final long CHAIN_ID = 220_879L;
private static final long H = 1_000L;
private static final long V2 = 1_005L;
private static final int N = 7;
private static final int K = 3;
private static final String SLH = "slh-dsa-sha2-128s";
@TempDir private Path tmp;
private final List<FalconPrivateKeyParameters> falconKeys = new ArrayList<>();
private final List<SealScheme.GeneratedPair> slhKeys = new ArrayList<>();
private final List<Address> validators = new ArrayList<>();
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
@BeforeEach
void setUp() throws Exception {
final KeccakDigest kd = new KeccakDigest(256);
final StringBuilder manifest = new StringBuilder();
manifest
.append("{\"config\":{\"aereFalconRegistry\":{")
.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
for (int i = 0; i < N; i++) {
falconKeys.add(PqV2Fixture.privateKey(i));
validators.add(PqV2Fixture.address(i));
final byte[] row = PqV2Fixture.anchorPreimageRow(i);
kd.update(row, 0, row.length);
manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
}
manifest
.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
.append("0".repeat(64))
.append("\":\"0x");
final byte[] anchoredHash = new byte[32];
kd.doFinal(anchoredHash, 0);
manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
final Path genesis = tmp.resolve("genesis-registry.json");
Files.writeString(genesis, manifest.toString());
System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString());
resetFalconSingleton();
PqSealCache.instance().clear();
armHybrid("0:falcon-512+" + SLH);
}
@AfterEach
void tearDown() throws Exception {
System.clearProperty("aere.falcon.genesis");
System.clearProperty(HybridSealSupport.PROPERTY_SCHEDULE);
System.clearProperty(HybridSealSupport.PROPERTY_REGISTRY);
HybridSealSupport.resetForTesting();
resetFalconSingleton();
PqSealCache.instance().clear();
PqAnchorProducer.useConfigForTesting(null);
}
/** Writes a hybrid-1 registry with the seven Falcon addresses and seven SLH-DSA keys, and loads it. */
private void armHybrid(final String schedule) throws Exception {
if (slhKeys.isEmpty()) {
for (int i = 0; i < N; i++) {
slhKeys.add(SealSchemes.SLH_DSA_128S.generate(random));
}
}
final StringBuilder p = new StringBuilder();
p.append("formatVersion=hybrid-1\nchainId=").append(CHAIN_ID).append("\ncount=").append(N).append('\n');
for (int i = 0; i < N; i++) {
p.append(i).append(".addr=").append(validators.get(i).toHexString()).append('\n');
p.append(i).append(".key.").append(SLH).append('=')
.append(Bytes.wrap(slhKeys.get(i).publicRegistryForm()).toHexString()).append('\n');
}
final Path reg = tmp.resolve("hybrid-registry-" + schedule.hashCode() + ".properties");
Files.writeString(reg, p.toString());
System.setProperty(HybridSealSupport.PROPERTY_SCHEDULE, schedule);
System.setProperty(HybridSealSupport.PROPERTY_REGISTRY, reg.toAbsolutePath().toString());
HybridSealSupport.resetForTesting();
assertThat(HybridSealSupport.instance().registry()).describedAs("hybrid registry loaded").isPresent();
}
// ------------------------------------------------------------------------------------ proofs
@Test
void atTheV2HeightTheProposerWritesASchemeTaggedCertificateUnderTheV2Digest() {
PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K)));
final BlockHeader parent = parentOf(V2 + 9L);
hearFalcon(parent, N);
hearExtras(parent, N);
final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators));
assertThat(produced.getFalconSeals()).describedAs("the v1 list is EMPTY on a v2 header").isEmpty();
final List<SchemeSeal> tagged = produced.getHybridSeals();
assertThat(PqAnchorV2.distinctValidatorsWith(tagged, SealSchemes.FALCON_512.wireId()))
.describedAs("K Falcon seals, capped exactly as the v1 producer caps them")
.isEqualTo(K);
assertThat(PqAnchorV2.distinctValidatorsWith(tagged, SealSchemes.SLH_DSA_128S.wireId()))
.describedAs("K SLH-DSA seals, each bound to a Falcon-certified index")
.isEqualTo(K);
for (final SchemeSeal s : tagged) {
if (s.getSchemeWireId() == SealSchemes.SLH_DSA_128S.wireId()) {
assertThat(tagged.stream().anyMatch(f -> f.getSchemeWireId() == SealSchemes.FALCON_512.wireId()
&& f.getValidatorIndex() == s.getValidatorIndex()))
.describedAs("SLH-DSA seal of index %s rides with a Falcon seal of the same index", s.getValidatorIndex())
.isTrue();
}
}
assertThat(produced.getVanityData())
.describedAs("vanityData is the v2 digest over exactly the carried certificate")
.isEqualTo(PqAnchorV2.anchorDigestV2(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), tagged));
// and NOT the v1 digest over the Falcon half, which is what a v1 rule would recompute
final List<FalconSeal> falconHalf = new ArrayList<>();
for (final SchemeSeal s : tagged) {
if (s.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) {
falconHalf.add(new FalconSeal(s.getValidatorIndex(), s.getSignature()));
}
}
assertThat(produced.getVanityData())
.isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), falconHalf));
}
@Test
void belowTheV2HeightNothingChangesEvenWithExtrasHeard() {
PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K)));
final BlockHeader parent = parentOf(V2 - 2L); // block V2-1 is an anchor height below V2
hearFalcon(parent, N);
hearExtras(parent, N);
final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators));
assertThat(produced.getHybridSeals()).describedAs("no scheme-tagged certificate below V2").isEmpty();
assertThat(produced.getFalconSeals()).hasSize(K);
assertThat(produced.getVanityData())
.isEqualTo(PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(),
PqAnchor.sortedByIndex(new ArrayList<>(produced.getFalconSeals()))));
}
@Test
void withNoSlhDsaSealHeardTheProposerRefusesInsteadOfWritingAShortCertificate() {
PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K)));
final BlockHeader parent = parentOf(V2 + 9L);
hearFalcon(parent, N);
// nothing recorded in the extras slot
assertThatThrownBy(() -> PqAnchorProducer.apply(base(), parent, contextWith(validators)))
.isInstanceOf(PqAnchorNotReadyException.class)
.hasMessageContaining(SLH);
// and with only K-1 SLH-DSA seals it still refuses: K per scheme, not "some"
hearExtras(parent, K - 1);
assertThatThrownBy(() -> PqAnchorProducer.apply(base(), parent, contextWith(validators)))
.isInstanceOf(PqAnchorNotReadyException.class)
.hasMessageContaining(SLH);
}
@Test
void anSlhDsaSealFromAnIndexWithoutAFalconSealIsNotCounted() {
PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K)));
final BlockHeader parent = parentOf(V2 + 9L);
hearFalcon(parent, K); // Falcon from indices 0..K-1 only
// SLH-DSA from indices K..N-1 only: none of them is Falcon-certified
final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
final List<SchemeSeal> extras = new ArrayList<>();
for (int i = K; i < N; i++) {
extras.add(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), i, Bytes.wrap(slhSign(i, m))));
}
PqSealCache.instance().recordExtras(parent.getNumber(), parent.getHash(), extras);
assertThatThrownBy(() -> PqAnchorProducer.apply(base(), parent, contextWith(validators)))
.isInstanceOf(PqAnchorNotReadyException.class)
.hasMessageContaining("bound to a Falcon-certified index");
}
@Test
void aFalconOnlyScheduleYieldsAFalconOnlyV2Certificate() throws Exception {
armHybrid("0:falcon-512");
PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K)));
final BlockHeader parent = parentOf(V2 + 9L);
hearFalcon(parent, N);
hearExtras(parent, N); // heard, but the schedule does not ask for them
final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators));
assertThat(produced.getHybridSeals()).hasSize(K);
assertThat(PqAnchorV2.distinctValidatorsWith(produced.getHybridSeals(), SealSchemes.SLH_DSA_128S.wireId()))
.describedAs("a scheme the schedule does not name is never written, heard or not")
.isZero();
}
@Test
void theSealCacheKeepsExtrasBesideTheFalconSealsUnderTheSameKey() {
final BlockHeader parent = parentOf(V2 + 9L);
hearFalcon(parent, N);
hearExtras(parent, N);
assertThat(PqSealCache.instance().sealsFor(parent.getNumber(), parent.getHash())).hasSize(N);
final List<SchemeSeal> extras = PqSealCache.instance().extrasFor(parent.getNumber(), parent.getHash());
assertThat(extras).hasSize(N);
assertThat(extras).isSortedAccordingTo(PqAnchorV2.CANONICAL);
// a Falcon-tagged seal offered to the extras slot is dropped: Falcon has its own slot
PqSealCache.instance().recordExtras(parent.getNumber(), parent.getHash(),
List.of(new SchemeSeal(SealSchemes.FALCON_512.wireId(), 0, Bytes.of(1, 2, 3))));
assertThat(PqSealCache.instance().extrasFor(parent.getNumber(), parent.getHash())).hasSize(N);
// and a different hash under the same height is a different entry
assertThat(PqSealCache.instance().extrasFor(parent.getNumber(), parentOf(V2 + 8L).getHash())).isEmpty();
}
// ------------------------------------------------------------------------------------ helpers
private static PqAnchorConfig config(final long v2Block, final OptionalInt cap) {
return new PqAnchorConfig(
CHAIN_ID, H, Map.of(H, K), OptionalInt.empty(), false, cap, OptionalInt.empty(), v2Block);
}
private static BlockHeader parentOf(final long number) {
return new BlockHeaderTestFixture().number(number).buildHeader();
}
private BftExtraData base() {
return new BftExtraData(
Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, validators, Collections.emptyList());
}
private void hearFalcon(final BlockHeader parent, final int howMany) {
final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
final List<FalconSeal> heard = new ArrayList<>();
for (int i = 0; i < howMany; i++) {
heard.add(new FalconSeal(i, Bytes.wrap(falconSign(falconKeys.get(i), m))));
}
PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard);
}
private void hearExtras(final BlockHeader parent, final int howMany) {
final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
final List<SchemeSeal> extras = new ArrayList<>();
for (int i = 0; i < howMany; i++) {
extras.add(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), i, Bytes.wrap(slhSign(i, m))));
}
PqSealCache.instance().recordExtras(parent.getNumber(), parent.getHash(), extras);
}
private byte[] slhSign(final int index, final Bytes32 m) {
return SealSchemes.SLH_DSA_128S.sign(slhKeys.get(index).privateKey(), m.toArray()).orElseThrow();
}
private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) {
final FalconSigner signer = new FalconSigner();
signer.init(true, key);
return signer.generateSignature(m.toArray());
}
private static ProtocolContext contextWith(final Collection<Address> vs) {
final ValidatorProvider validatorProvider =
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(vs);
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(vs);
final BftContext bftContext =
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
when(bftContext.as(any())).thenReturn(bftContext);
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
}
private static void resetFalconSingleton() throws Exception {
final Field f = FalconSealSupport.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, null);
}
}

View File

@ -620,4 +620,33 @@ public class PqSealPersistenceTest {
f.setAccessible(true);
f.set(null, null);
}
/**
* D-330 (2026-09-03, testnet 28001): the main file followed the highest height HEARD, which was a
* proposal that never imported (168032), so after a fleet-wide restart no node held the seals of
* the head (168031). The previous slot keeps them one height longer.
*/
@Test
public void theSealsOfTheHeadSurviveTheStoreMovingToALaterHeightThatNeverImported() {
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
for (int i = 0; i < K; i++) {
PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(i)));
}
// the fleet now hears commits for a NEXT block that will never import
final BlockHeader next = new BlockHeaderTestFixture().number(head.getNumber() + 1L).buildHeader();
final Bytes32 mNext = PqAnchor.commitMessage(CHAIN_ID, next.getNumber(), next.getHash().getBytes());
final FalconSigner signer = new FalconSigner();
signer.init(true, privateKeys.get(0));
PqSealCache.instance()
.record(next.getNumber(), next.getHash(),
List.of(new FalconSeal(0, Bytes.wrap(signer.generateSignature(mNext.toArray())))));
assertThat(PqSealStore.previousFileIn(dataDirectory)).describedAs("the previous slot exists after the rotation").exists();
// restart: memory gone, the main file holds the never-imported height
PqSealCache.instance().clear();
final int restored =
PqSealCache.instance()
.restoreFromDisk(head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport());
assertThat(restored).describedAs("the head's seals come back from the previous slot").isEqualTo(K);
assertThat(PqAnchorProducer.apply(base, head, context).getFalconSeals()).hasSize(K);
}
}

View File

@ -0,0 +1,82 @@
/*
* Copyright contributors to 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 java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.tuweni.bytes.Bytes;
import org.junit.jupiter.api.Test;
/**
* D-325 (2026-09-03), the CROSS-IMPLEMENTATION vector: an SLH-DSA-SHA2-128s signature produced by the public
* verifier's library (@noble/post-quantum 0.7.1, FIPS 205 external form, empty context) must verify under this
* fork's {@link SlhDsaSealScheme} (BouncyCastle Java). Two implementations agreeing on one vector is the only
* proof that "the same form" is the same form; two self-consistent implementations prove nothing about each other.
*
* <p>With its pair: the same signature over a message that differs in one bit does NOT verify, and a signature with
* one bit flipped does NOT verify. A verifier that accepted everything would pass the positive half alone.
*/
class SlhDsaCrossVectorTest {
private static String field(final String json, final String name) {
final Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([0-9a-fA-F]+)\"").matcher(json);
if (!m.find()) {
throw new IllegalStateException("vector has no field " + name);
}
return m.group(1);
}
private static String vector() throws IOException {
try (InputStream in =
SlhDsaCrossVectorTest.class.getResourceAsStream("/vector-slh-dsa-noble-2026-09-03.json")) {
if (in == null) {
throw new IllegalStateException("vector-slh-dsa-noble-2026-09-03.json is not on the test classpath");
}
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
@Test
void aSignatureFromTheJavaScriptImplementationVerifiesHereAndItsTamperedTwinsDoNot() throws IOException {
final String v = vector();
final byte[] pk = Bytes.fromHexString(field(v, "pk")).toArray();
final byte[] msg = Bytes.fromHexString(field(v, "msg")).toArray();
final byte[] sig = Bytes.fromHexString(field(v, "sig")).toArray();
assertThat(pk).hasSize(32);
assertThat(msg).hasSize(32);
assertThat(sig).hasSize(7856);
final SealScheme scheme = SealSchemes.SLH_DSA_128S;
assertThat(scheme.verifyRaw(pk, msg, sig))
.describedAs("the @noble/post-quantum signature verifies under BouncyCastle's SLH-DSA-SHA2-128s (external form)")
.isTrue();
final byte[] otherMsg = msg.clone();
otherMsg[0] ^= 0x01;
assertThat(scheme.verifyRaw(pk, otherMsg, sig)).describedAs("one message bit flipped").isFalse();
final byte[] badSig = sig.clone();
badSig[badSig.length / 2] ^= 0x01;
assertThat(scheme.verifyRaw(pk, msg, badSig)).describedAs("one signature bit flipped").isFalse();
// and the INTERNAL form (what the precompile at 0x0AE4 uses) must NOT be confused with it: a scheme that
// accepted both would let a seal stand in for a precompile call or the other way round.
assertThat(SealSchemes.byId("slh-dsa-sha2-128s")).isPresent();
assertThat(SealSchemes.byId("slh-dsa-128s")).describedAs("the pre-D-325 alias still resolves").isPresent();
}
}

View File

@ -0,0 +1,141 @@
/*
* Copyright contributors to 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 java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyGenerationParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyPairGenerator;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPrivateKeyParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPublicKeyParameters;
import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSASigner;
import org.hyperledger.besu.crypto.SecureRandomProvider;
import org.junit.jupiter.api.Test;
/**
* D-337 (2026-09-04): the in-tree SLH-DSA engine (Bouncy Castle's algorithm on the JDK's SHA-NI
* digest) must be the SAME signature scheme as the Bouncy Castle original, bit for bit. Otherwise a
* faster signer is a different certificate, and the second client and the public verifier would
* reject every anchor. Each direction is pinned: fast-signed verifies under the original, original-
* signed verifies under the fast engine, deterministic signing gives identical bytes, and the fast
* engine still rejects a tampered signature (the negative control of the verifier).
*/
class SlhDsaFastEngineTest {
private static final org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters ORIGINAL =
org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters.sha2_128s;
private static AsymmetricCipherKeyPair fastKeyPair() {
final SLHDSAKeyPairGenerator gen = new SLHDSAKeyPairGenerator();
gen.init(new SLHDSAKeyGenerationParameters(SecureRandomProvider.createSecureRandom(), SLHDSAParameters.sha2_128s));
return gen.generateKeyPair();
}
private static org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters originalPub(
final AsymmetricCipherKeyPair kp) {
return new org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters(
ORIGINAL, ((SLHDSAPublicKeyParameters) kp.getPublic()).getEncoded());
}
private static org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters originalPriv(
final AsymmetricCipherKeyPair kp) {
return new org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters(
ORIGINAL, ((SLHDSAPrivateKeyParameters) kp.getPrivate()).getEncoded());
}
@Test
void aFastSignatureVerifiesUnderTheOriginalBouncyCastleEngine() {
final AsymmetricCipherKeyPair kp = fastKeyPair();
final byte[] msg = "AERE-PQ-ANCHOR-2 commit seal message".getBytes(StandardCharsets.UTF_8);
final SLHDSASigner signer = new SLHDSASigner();
signer.init(true, kp.getPrivate());
final byte[] sig = signer.generateSignature(msg);
assertThat(sig).hasSize(7856);
final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original =
new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner();
original.init(false, originalPub(kp));
assertThat(original.verifySignature(msg, sig)).isTrue();
// negative control of the ORIGINAL verifier on the fast signature: one bit off, rejected
final byte[] bad = sig.clone();
bad[1234] ^= 0x01;
original.init(false, originalPub(kp));
assertThat(original.verifySignature(msg, bad)).isFalse();
}
@Test
void anOriginalSignatureVerifiesUnderTheFastEngineAndATamperedOneDoesNot() {
final AsymmetricCipherKeyPair kp = fastKeyPair();
final byte[] msg = new byte[32];
msg[31] = 7;
final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original =
new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner();
original.init(true, originalPriv(kp));
final byte[] sig = original.generateSignature(msg);
final SLHDSASigner fast = new SLHDSASigner();
fast.init(false, kp.getPublic());
assertThat(fast.verifySignature(msg, sig)).isTrue();
final byte[] bad = sig.clone();
bad[100] ^= 0x01;
fast.init(false, kp.getPublic());
assertThat(fast.verifySignature(msg, bad)).isFalse();
final byte[] otherMsg = msg.clone();
otherMsg[0] ^= 0x01;
fast.init(false, kp.getPublic());
assertThat(fast.verifySignature(otherMsg, sig)).isFalse();
}
@Test
void deterministicSigningGivesTheSameBytesAsTheOriginal() {
// Without a SecureRandom both engines use the pk seed as opt_rand (FIPS 205 deterministic
// variant), so the SAME key and message must give the SAME 7856 bytes. This is the strongest
// statement that the hash swap changed nothing but the clock.
final AsymmetricCipherKeyPair kp = fastKeyPair();
final byte[] msg = "deterministic".getBytes(StandardCharsets.UTF_8);
final SLHDSASigner fast = new SLHDSASigner();
fast.init(true, kp.getPrivate());
final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original =
new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner();
original.init(true, originalPriv(kp));
assertThat(Arrays.equals(fast.generateSignature(msg), original.generateSignature(msg)))
.isTrue();
}
@Test
void theSealSchemeItselfSignsThroughTheFastEngineAndTheOriginalAccepts() {
final SlhDsaSealScheme scheme = new SlhDsaSealScheme();
final SealScheme.GeneratedPair pair = scheme.generate(SecureRandomProvider.createSecureRandom());
final byte[] msg = new byte[32];
final byte[] sig = scheme.sign(pair.privateKey(), msg).orElseThrow();
final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original =
new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner();
original.init(
false,
new org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters(
ORIGINAL, pair.publicRegistryForm()));
assertThat(original.verifySignature(msg, sig)).isTrue();
assertThat(scheme.verifyRaw(pair.publicRegistryForm(), msg, sig)).isTrue();
}
}

View File

@ -68,6 +68,20 @@ public class Commit extends BftMessage<CommitPayload> {
*
* @return the digest
*/
/**
* D-339 (2026-09-04): the hybrid extra seals this Commit carries, as a plain accessor.
*
* <p>It exists so the two places that keep seals out of a dying message (the late-seal salvage in
* the controller and the no-proposal path in the round) do not have to reach through the signed
* payload. Reaching through it also broke every test that mocks a Commit, which is exactly the
* kind of surprise a wrapper class is meant to absorb.
*
* @return the non-Falcon scheme seals, empty on every commit that carries none
*/
public java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> getExtraSeals() {
return getPayload().getExtraSeals();
}
public Hash getDigest() {
return getPayload().getDigest();
}

View File

@ -270,6 +270,14 @@ public class QbftController implements QbftEventHandler {
return;
}
PqSealCache.instance().record(head, headHeader.getHash(), List.of(seal.get()));
// D-339 (2026-09-04): the hybrid extras ride the SAME late commits, and until today this path
// dropped them. The anchor needs K seals of EVERY scheme, so a salvaged Falcon seal without its
// SLH-DSA twin still left the proposer short and the anchor still lost its round.
final List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extras =
commit.getExtraSeals();
if (!extras.isEmpty()) {
PqSealCache.instance().recordExtras(head, headHeader.getHash(), extras);
}
LOG.trace("AERE D-227: salvaged a late Falcon seal for imported block {}", head);
}

View File

@ -512,6 +512,9 @@ public class QbftRound {
// this node has already imported the block. Those late seals are exactly the ones that let a
// proposer reach the threshold K without waiting for its own round to be re-run.
roundState.getProposedBlock().ifPresent(this::pqCacheHeardSeals);
if (roundState.getProposedBlock().isEmpty()) {
pqCacheSealsOfCommitWithoutProposal(msg);
}
// AERE hybrid PQC: attempt import on every commit while not yet imported. In the common case
// this fires exactly once (on the quorum-th commit). Post-fork, if the first attempt lacked a
// complete Falcon quorum certificate (e.g. one of the quorum's validators had a faulty Falcon
@ -589,12 +592,52 @@ public class QbftRound {
* on-chain hash, which is the key the producer of the next block looks under. Cheap and
* idempotent: the cache keeps the first seal seen per validator index.
*/
/**
* D-339 (2026-09-04): keep the seals of a Commit even when this node has NO proposal for the
* round, keyed on the digest the Commit itself names.
*
* <p>Why, measured on chain 2800 the same evening: the proposer of an anchor kept refusing with
* "this node holds 1 valid eligible Falcon seal ... 0 rejected" - it was not rejecting seals, it
* had never heard them. The path above caches only when the round holds the proposed block, so a
* node that missed the proposal of the parent's round (or imported that block by any route other
* than its own round) collected the Commits and threw their seals away. It then could not build
* the certificate of the next anchor, the round expired, and every 32nd height paid the
* round-change timeout. Eleven of twelve anchors measured before this fix.
*
* <p>Safety: only the seal is copied, never the message; the digest is the one the Commit names,
* so a losing round writes an entry under a hash that never becomes canonical and is simply never
* looked up; and the producer Falcon-verifies every cached seal against the anchored registry
* before carrying it, so a seal that lies about its index still cannot reach a header.
*
* @param msg the commit whose seals are kept
*/
private void pqCacheSealsOfCommitWithoutProposal(final Commit msg) {
final long height = roundState.getRoundIdentifier().getSequenceNumber();
msg.getFalconSeal()
.ifPresent(
seal ->
PqSealCache.instance()
.record(height, msg.getDigest(), java.util.List.of(seal)));
final java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extras =
msg.getExtraSeals();
if (!extras.isEmpty()) {
PqSealCache.instance().recordExtras(height, msg.getDigest(), extras);
}
}
private void pqCacheHeardSeals(final QbftBlock block) {
final Collection<FalconSeal> seals = roundState.getFalconSeals();
if (seals.isEmpty()) {
return;
}
PqSealCache.instance().record(block.getHeader().getNumber(), pqOnchainHashOf(block), seals);
final Hash onchain = pqOnchainHashOf(block);
PqSealCache.instance().record(block.getHeader().getNumber(), onchain, seals);
// AERE ANCHOR V2: the extra scheme seals ride the same commits and go under the same key.
final java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extras =
roundState.getExtraSeals();
if (!extras.isEmpty()) {
PqSealCache.instance().recordExtras(block.getHeader().getNumber(), onchain, extras);
}
}
/**
@ -619,20 +662,54 @@ public class QbftRound {
*/
private java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extraSealsFor(
final QbftBlock block, final Hash commitHash) {
// ANCHOR PARENTS ONLY (2026-09-03, D-329): an SLH-DSA-128s signature costs seconds here; it is
// only ever used in the next anchor's certificate, which is built over the anchor's parent. So
// the extras are signed only when the block being committed is the parent of an anchor height.
// The same predicate gates the enforcement (PqCommitEnforcement.anchorParentByConfig).
final long number = block.getHeader().getNumber();
if (!org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config()
.anchorAppliesAt(number + 1L)) {
return java.util.List.of();
}
return hybridExtrasOrEmpty(number, pqSealMessageFor(block, commitHash));
}
/**
* D-334 (2026-09-03, mainnet 2800, 125 s of halted chain): the hybrid loader is lazy, so a
* registry the container could not read surfaced here, as an exception thrown INTO the QBFT
* state machine, at every anchor parent, on every armed node. Three such nodes plus one node
* restarting left five of nine. A broken hybrid configuration may cost this node its SLH-DSA
* contribution; it must never cost the node its vote. The loader still refuses to START on the
* same defect (QbftBesuControllerBuilder calls it at boot), so this catch is the second net.
*
* @param number the height being committed
* @param message the seal message
* @return the extras, or an empty list with an ERROR line when the hybrid support cannot sign
*/
static java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> hybridExtrasOrEmpty(
final long number, final Bytes32 message) {
try {
return org.hyperledger.besu.consensus.common.bft.HybridSealSupport.instance()
.producer()
.sealsFor(block.getHeader().getNumber(), pqSealMessageFor(block, commitHash));
.sealsFor(number, message);
} catch (final RuntimeException e) {
LOG.error(
"AERE HIBRID: extras NOT signed at height {} - {}. This node keeps voting but contributes no"
+ " SLH-DSA seal until the hybrid configuration is repaired (D-334).",
number,
e.getMessage());
return java.util.List.of();
}
}
/** The exact bytes a PQ seal over this block signs; shared by Falcon and the hybrid extras,
* so the two halves of a hybrid certificate can never drift onto different messages. */
private Bytes32 pqSealMessageFor(final QbftBlock block, final Hash commitHash) {
final long blockNumber = block.getHeader().getNumber();
if (PqAnchorProducer.sealMessageIsAnchorForm(blockNumber)) {
return PqAnchor.commitMessage(
PqAnchorProducer.config().chainId(), blockNumber, pqOnchainHashOf(block).getBytes());
}
return Bytes32.wrap(commitHash.getBytes());
// AERE D-311 (2026-09-02): the form is decided in ONE place, shared with the verifier
// (MessageValidator.SubsequentMessageValidator). A second copy of this rule drifted once and
// stopped a chain; see PqAnchorProducer.commitSealMessage.
return PqAnchorProducer.commitSealMessage(
block.getHeader().getNumber(), () -> pqOnchainHashOf(block).getBytes(), commitHash);
}
private Optional<FalconSeal> falconSealFor(final QbftBlock block, final Hash commitHash) {

View File

@ -212,6 +212,18 @@ public class RoundState {
*
* @return the gossiped Falcon seals (possibly empty, never null)
*/
/**
* AERE ANCHOR V2: the extra (non-Falcon) scheme seals gossiped on the collected commit messages,
* raw and unverified, for the seal cache and the v2 certificate producer.
*
* @return the gossiped scheme seals (possibly empty, never null)
*/
public java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> getExtraSeals() {
return commitMessages.values().stream()
.flatMap(cp -> cp.getSignedPayload().getPayload().getExtraSeals().stream())
.collect(Collectors.toList());
}
public Collection<FalconSeal> getFalconSeals() {
return commitMessages.values().stream()
.map(cp -> cp.getSignedPayload().getPayload().getFalconSeal())

View File

@ -47,6 +47,11 @@ public class CommitValidator {
private final ConsensusRoundIdentifier targetRound;
private final Hash expectedDigest;
private final Hash expectedCommitDigest;
// AERE D-311 (2026-09-02): the message the post-quantum seal must have signed. It is NOT always
// the committed-seal digest: once the anchor is armed the emitter signs the anchor form (see
// PqAnchorProducer.commitSealMessage). The upstream-shaped constructors keep the digest, so no
// existing caller changes behaviour; the production path (MessageValidator) passes the real one.
private final Hash expectedPqSealMessage;
// AERE full-PQ: optional height-gated enforcement. Null means "upstream behaviour", which is
// exactly what the pre-existing constructor supplies, so nothing changes for existing callers.
private final PqCommitEnforcement pqEnforcement;
@ -75,6 +80,33 @@ public class CommitValidator {
targetRound,
expectedDigest,
expectedCommitDigest,
expectedCommitDigest,
PqCommitEnforcement.fromSystemConfig());
}
/**
* AERE D-311: the production constructor. Self-wires enforcement from the system configuration
* and takes the exact message the PQ seal signs at this height, computed by the caller with
* {@code PqAnchorProducer.commitSealMessage}, the same helper the emitter uses.
*
* @param validators the validators
* @param targetRound the target round
* @param expectedDigest the expected digest
* @param expectedCommitDigest the expected commit digest (ECDSA committed seal)
* @param expectedPqSealMessage the message the post-quantum seal must have signed
*/
public CommitValidator(
final Collection<Address> validators,
final ConsensusRoundIdentifier targetRound,
final Hash expectedDigest,
final Hash expectedCommitDigest,
final Hash expectedPqSealMessage) {
this(
validators,
targetRound,
expectedDigest,
expectedCommitDigest,
expectedPqSealMessage,
PqCommitEnforcement.fromSystemConfig());
}
@ -98,10 +130,37 @@ public class CommitValidator {
final Hash expectedDigest,
final Hash expectedCommitDigest,
final PqCommitEnforcement pqEnforcement) {
this(validators, targetRound, expectedDigest, expectedCommitDigest, expectedCommitDigest, pqEnforcement);
}
/** AERE D-311: the message the post-quantum seal must have signed; for the plumbing proof only. */
@com.google.common.annotations.VisibleForTesting
Hash expectedPqSealMessageForTesting() {
return expectedPqSealMessage;
}
/**
* AERE D-311: the fully explicit constructor (tests and the production constructor above).
*
* @param validators the validators
* @param targetRound the target round
* @param expectedDigest the expected digest
* @param expectedCommitDigest the expected commit digest (ECDSA committed seal)
* @param expectedPqSealMessage the message the post-quantum seal must have signed
* @param pqEnforcement the enforcement, or null for upstream behaviour
*/
public CommitValidator(
final Collection<Address> validators,
final ConsensusRoundIdentifier targetRound,
final Hash expectedDigest,
final Hash expectedCommitDigest,
final Hash expectedPqSealMessage,
final PqCommitEnforcement pqEnforcement) {
this.validators = validators;
this.targetRound = targetRound;
this.expectedDigest = expectedDigest;
this.expectedCommitDigest = expectedCommitDigest;
this.expectedPqSealMessage = expectedPqSealMessage;
this.pqEnforcement = pqEnforcement;
}
@ -166,7 +225,7 @@ public class CommitValidator {
pqEnforcement.refusal(
targetRound.getSequenceNumber(),
signedPayload.getAuthor(),
expectedCommitDigest,
expectedPqSealMessage,
payload.getFalconSeal(),
payload.getExtraSeals());
if (refusal.isPresent()) {

View File

@ -0,0 +1,193 @@
/*
* Copyright ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.qbft.core.validation;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Hash;
import java.util.Collection;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** The Message validator. */
public class MessageValidator {
private static final Logger LOG = LoggerFactory.getLogger(MessageValidator.class);
/** The Subsequent message validator. */
public static class SubsequentMessageValidator {
private final PrepareValidator prepareValidator;
private final CommitValidator commitValidator;
/**
* Instantiates a new Subsequent message validator.
*
* @param validators the validators
* @param targetRound the target round
* @param proposalBlock the proposal block
* @param blockInterface the block interface
*/
public SubsequentMessageValidator(
final Collection<Address> validators,
final ConsensusRoundIdentifier targetRound,
final QbftBlock proposalBlock,
final QbftBlockInterface blockInterface) {
final QbftBlock commitBlock =
blockInterface.replaceRoundForCommitBlock(proposalBlock, targetRound.getRoundNumber());
prepareValidator = new PrepareValidator(validators, targetRound, proposalBlock.getHash());
// AERE D-311 (2026-09-02): the post-quantum seal on a commit signs the message that
// PqAnchorProducer.commitSealMessage names (the anchor form once the anchor is armed, the
// ECDSA committed-seal hash before), computed here exactly as the emitter computes it in
// QbftRound.pqSealMessageFor: same helper, same round-independent on-chain hash.
final Hash expectedPqSealMessage =
Hash.wrap(
PqAnchorProducer.commitSealMessage(
targetRound.getSequenceNumber(),
() -> blockInterface.replaceRoundForCommitBlock(proposalBlock, 0).getHash().getBytes(),
commitBlock.getHash()));
commitValidator =
new CommitValidator(
validators,
targetRound,
proposalBlock.getHash(),
commitBlock.getHash(),
expectedPqSealMessage);
}
/**
* Validate.
*
* @param msg the Prepare payload msg
* @return the boolean
*/
public boolean validate(final Prepare msg) {
return prepareValidator.validate(msg);
}
/**
* Validate.
*
* @param msg the Commit payload msg
* @return the boolean
*/
/** AERE D-311: the commit validator this wiring built; for the plumbing proof only. */
@com.google.common.annotations.VisibleForTesting
CommitValidator commitValidatorForTesting() {
return commitValidator;
}
public boolean validate(final Commit msg) {
return commitValidator.validate(msg);
}
}
/** The interface Subsequent message validator factory. */
@FunctionalInterface
public interface SubsequentMessageValidatorFactory {
/**
* Create subsequent message validator.
*
* @param proposalBlock the proposal block
* @return the subsequent message validator
*/
SubsequentMessageValidator create(QbftBlock proposalBlock);
}
private final SubsequentMessageValidatorFactory subsequentMessageValidatorFactory;
private final ProposalValidator proposalValidator;
private Optional<SubsequentMessageValidator> subsequentMessageValidator = Optional.empty();
/**
* Instantiates a new Message validator.
*
* @param subsequentMessageValidatorFactory the subsequent message validator factory
* @param proposalValidator the proposal validator
*/
public MessageValidator(
final SubsequentMessageValidatorFactory subsequentMessageValidatorFactory,
final ProposalValidator proposalValidator) {
this.subsequentMessageValidatorFactory = subsequentMessageValidatorFactory;
this.proposalValidator = proposalValidator;
}
/**
* Validate proposal payload.
*
* @param msg the Proposal payload msg
* @return the boolean
*/
public boolean validateProposal(final Proposal msg) {
return validateProposal(msg, true);
}
/**
* Validate proposal payload without block validation.
*
* @param msg the Proposal payload msg
* @return whether the proposal is valid
*/
public boolean validateProposalWithoutBlockValidation(final Proposal msg) {
return validateProposal(msg, false);
}
private boolean validateProposal(final Proposal msg, final boolean validateBlock) {
if (subsequentMessageValidator.isPresent()) {
LOG.info("Received subsequent Proposal for current round, discarding.");
return false;
}
final boolean result =
validateBlock
? proposalValidator.validate(msg)
: proposalValidator.validateWithoutBlockValidation(msg);
if (result) {
subsequentMessageValidator =
Optional.of(subsequentMessageValidatorFactory.create(msg.getBlock()));
}
return result;
}
/**
* Validate Prepare payload.
*
* @param msg the Prepare msg
* @return the boolean
*/
public boolean validatePrepare(final Prepare msg) {
return subsequentMessageValidator.map(pv -> pv.validate(msg)).orElse(false);
}
/**
* Validate commit payload.
*
* @param msg the Commit payload
* @return the boolean
*/
public boolean validateCommit(final Commit msg) {
return subsequentMessageValidator.map(cv -> cv.validate(msg)).orElse(false);
}
}

View File

@ -26,12 +26,14 @@ import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
import org.hyperledger.besu.consensus.common.bft.SealScheme;
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Hash;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.LongPredicate;
import org.apache.tuweni.bytes.Bytes;
@ -110,6 +112,7 @@ public final class PqCommitEnforcement {
// AERE HIBRID: null in Falcon-only mode, which is every node today.
private final PqSchemeSchedule schemeSchedule;
private final HybridSignerRegistry hybridRegistry;
private final LongPredicate extrasRequiredAt;
/**
* @param armedFromBlock first block height (inclusive) at which enforcement applies; use
@ -146,10 +149,41 @@ public final class PqCommitEnforcement {
final PqSignerRegistry registry,
final PqSchemeSchedule schemeSchedule,
final HybridSignerRegistry hybridRegistry) {
this(armedFromBlock, registry, schemeSchedule, hybridRegistry, anchorParentByConfig());
}
/**
* ANCHOR PARENTS ONLY (2026-09-03, D-329). An SLH-DSA-SHA2-128s signature costs seconds in this
* runtime; demanded on EVERY commit it stretched the testnet from 0.5 s to 4 s per block. The
* extra seal is only ever USED in the certificate of the next anchor, which is built from the
* seals heard over the anchor's PARENT. So the extra scheme seals are required (and emitted, see
* QbftRound) only on commits over a block whose successor is an anchor height. Falcon stays on
* every commit, exactly as before.
*
* @return the predicate "the block at this height is the parent of an anchor", from the live
* anchor configuration
*/
public static LongPredicate anchorParentByConfig() {
return h -> PqAnchorProducer.config().anchorAppliesAt(h + 1L);
}
/**
* Full constructor.
*
* @param extrasRequiredAt at which heights the extra (non-Falcon) scheme seals are demanded on a
* commit; production passes {@link #anchorParentByConfig()}
*/
public PqCommitEnforcement(
final long armedFromBlock,
final PqSignerRegistry registry,
final PqSchemeSchedule schemeSchedule,
final HybridSignerRegistry hybridRegistry,
final LongPredicate extrasRequiredAt) {
this.armedFromBlock = armedFromBlock;
this.registry = registry;
this.schemeSchedule = schemeSchedule;
this.hybridRegistry = hybridRegistry;
this.extrasRequiredAt = extrasRequiredAt;
if ((schemeSchedule == null) != (hybridRegistry == null)) {
// Half a hybrid configuration is the shape that starts a node believing it enforces
// something it does not. Refuse at construction, the same stance as every other AERE gate.
@ -198,7 +232,10 @@ public final class PqCommitEnforcement {
final Optional<FalconSeal> seal,
final List<SchemeSeal> extraSeals) {
final Optional<String> falconVerdict = falconRefusal(height, author, commitDigest, seal);
if (falconVerdict.isPresent() || !armedAt(height) || schemeSchedule == null) {
if (falconVerdict.isPresent()
|| !armedAt(height)
|| schemeSchedule == null
|| !extrasRequiredAt.test(height)) {
return falconVerdict;
}
return hybridRefusal(height, author, commitDigest, seal.orElseThrow(), extraSeals);

View File

@ -0,0 +1,69 @@
/*
* Copyright contributors to Hyperledger Besu.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.qbft.core.statemachine;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.hyperledger.besu.consensus.common.bft.HybridSealSupport;
import org.apache.tuweni.bytes.Bytes32;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* D-334 (2026-09-03): a hybrid configuration the node cannot read must cost the node its SLH-DSA
* contribution, never its vote. The loader throws (the defect is real and named); the state
* machine's helper returns an empty list instead of letting that exception reach the QBFT event
* loop. Both halves are asserted, so the test cannot pass by the configuration being silently
* ignored.
*/
public class PqHybridExtrasNeverThrowTest {
private static final String[] PROPS = {
HybridSealSupport.PROPERTY_SCHEDULE,
HybridSealSupport.PROPERTY_REGISTRY,
HybridSealSupport.PROPERTY_ATTACH_BLOCK
};
@BeforeEach
public void plantAnUnreadableRegistry() {
HybridSealSupport.resetForTesting();
System.setProperty(HybridSealSupport.PROPERTY_SCHEDULE, "10:falcon-512+slh-dsa-sha2-128s");
System.setProperty(
HybridSealSupport.PROPERTY_REGISTRY, "/nonexistent/aere-d334/hibrid-1.properties");
}
@AfterEach
public void cleanUp() {
for (final String p : PROPS) {
System.clearProperty(p);
}
HybridSealSupport.resetForTesting();
}
@Test
public void theLoaderNamesTheDefect() {
assertThatThrownBy(HybridSealSupport::instance)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("AERE-PQC-HYBRID-CONF-04");
}
@Test
public void theStateMachineHelperReturnsEmptyInsteadOfThrowing() {
assertThat(QbftRound.hybridExtrasOrEmpty(31L, Bytes32.ZERO)).isEmpty();
}
}

View File

@ -0,0 +1,168 @@
/*
* Copyright contributors to 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.core.statemachine;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.apache.tuweni.bytes.Bytes;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.PqSealCache;
import org.hyperledger.besu.consensus.common.bft.RoundTimer;
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
import org.hyperledger.besu.consensus.qbft.core.network.QbftMessageTransmitter;
import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface;
import org.hyperledger.besu.consensus.qbft.core.types.QbftMinedBlockObserver;
import org.hyperledger.besu.consensus.qbft.core.types.QbftProtocolSchedule;
import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidator;
import org.hyperledger.besu.consensus.qbft.core.validation.QbftBlockHeaderTestFixture;
import org.hyperledger.besu.cryptoservices.NodeKey;
import org.hyperledger.besu.cryptoservices.NodeKeyUtils;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Hash;
import org.hyperledger.besu.util.Subscribers;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
/**
* D-339 (2026-09-04): a Commit teaches this node the seals of its block even when this node holds NO
* proposal for that round.
*
* <p>The measured defect, on chain 2800 the same evening: anchor proposers kept refusing with "this
* node holds 1 valid eligible Falcon seal ... 0 rejected" - they were not rejecting seals, they had
* never heard them. The cache was fed only from a round that held the proposed block, so a node that
* missed the proposal of the parent's round collected the Commits and dropped their seals; it then
* could not build the next anchor's certificate, the round expired, and every 32nd height paid the
* round-change timeout (eleven of twelve anchors before this fix).
*
* <p>What is pinned here: with no proposal in the round, a Commit's Falcon seal AND its hybrid extra
* seals land in the cache under the digest the Commit names; and the negative control, a Commit
* carrying no seals, leaves the cache empty.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class PqSealsWithoutProposalTest {
private final NodeKey nodeKey = NodeKeyUtils.generate();
private final Address localAddress = Address.extract(nodeKey.getPublicKey());
private final NodeKey peerKey = NodeKeyUtils.generate();
private final ConsensusRoundIdentifier roundIdentifier = new ConsensusRoundIdentifier(7, 0);
private final Subscribers<QbftMinedBlockObserver> subscribers = Subscribers.create();
private final Hash digest = Hash.hash(Bytes.fromHexString("0xdeadbeef"));
private final FalconSeal falcon = new FalconSeal(4, Bytes.fromHexString("0x29aabbcc"));
private final SchemeSeal slh = new SchemeSeal((byte) 0x02, 4, Bytes.fromHexString("0x0102030405"));
private MessageFactory localFactory;
private MessageFactory peerFactory;
@Mock private org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec blockEncoder;
@Mock private QbftProtocolSchedule protocolSchedule;
@Mock private QbftMessageTransmitter transmitter;
@Mock private MessageValidator messageValidator;
@Mock private RoundTimer roundTimer;
@Mock private QbftBlockCreator blockCreator;
@Mock private QbftBlockInterface blockInterface;
private final QbftBlockHeader parentHeader =
new QbftBlockHeaderTestFixture().number(6).buildHeader();
@BeforeEach
public void setup() {
PqSealCache.instance().clear();
localFactory = new MessageFactory(nodeKey, blockEncoder);
peerFactory = new MessageFactory(peerKey, blockEncoder);
when(messageValidator.validateCommit(org.mockito.ArgumentMatchers.any())).thenReturn(true);
}
@AfterEach
public void clean() {
PqSealCache.instance().clear();
}
private QbftRound roundWithoutProposal() {
final RoundState roundState = new RoundState(roundIdentifier, 3, messageValidator);
assertThat(roundState.getProposedBlock()).isEmpty();
return new QbftRound(
roundState,
blockCreator,
blockInterface,
protocolSchedule,
subscribers,
nodeKey,
localAddress,
localFactory,
transmitter,
roundTimer,
parentHeader);
}
@Test
public void aCommitWithoutAProposalStillTeachesItsSeals() {
final QbftRound round = roundWithoutProposal();
round.handleCommitMessage(
peerFactory.createCommit(
roundIdentifier,
digest,
peerKey.sign(org.apache.tuweni.bytes.Bytes32.ZERO),
Optional.of(falcon),
List.of(slh)));
assertThat(PqSealCache.instance().sealsFor(roundIdentifier.getSequenceNumber(), digest))
.containsExactly(falcon);
assertThat(PqSealCache.instance().extrasFor(roundIdentifier.getSequenceNumber(), digest))
.containsExactly(slh);
}
@Test
public void aCommitWithoutSealsLeavesTheCacheEmpty() {
// the negative control: the path must not write an entry just because a Commit arrived
final QbftRound round = roundWithoutProposal();
round.handleCommitMessage(
peerFactory.createCommit(roundIdentifier, digest, peerKey.sign(org.apache.tuweni.bytes.Bytes32.ZERO)));
assertThat(PqSealCache.instance().sealsFor(roundIdentifier.getSequenceNumber(), digest))
.isEmpty();
assertThat(PqSealCache.instance().extrasFor(roundIdentifier.getSequenceNumber(), digest))
.isEmpty();
assertThat(PqSealCache.instance().entryCount()).isZero();
}
@Test
public void sealsLandUnderTheDigestTheCommitNames() {
// a seal must never be filed under some other block's hash: the producer looks up by the
// parent's on-chain hash, and a mis-filed seal would be an invisible way to poison a lookup
final QbftRound round = roundWithoutProposal();
round.handleCommitMessage(
peerFactory.createCommit(
roundIdentifier, digest, peerKey.sign(org.apache.tuweni.bytes.Bytes32.ZERO), Optional.of(falcon), List.of(slh)));
final Hash altul = Hash.hash(Bytes.fromHexString("0xfeedface"));
assertThat(PqSealCache.instance().sealsFor(roundIdentifier.getSequenceNumber(), altul)).isEmpty();
}
}

View File

@ -0,0 +1,216 @@
/*
* Copyright contributors to the 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.core.validation;
import static org.assertj.core.api.Assertions.assertThat;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
import org.hyperledger.besu.consensus.common.bft.SealScheme;
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
import org.hyperledger.besu.crypto.SECPSignature;
import org.hyperledger.besu.crypto.SecureRandomProvider;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Hash;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import org.apache.tuweni.bytes.Bytes;
import org.apache.tuweni.bytes.Bytes32;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* AERE D-311 (2026-09-02). Found by the public testnet 28001 on its first day, with the options of
* fleet 2800: at {@code aere.pq.commitPq.forkBlock} every validator refused every commit ("post-quantum
* seal of index i does NOT verify over the commit digest") and the chain stopped. The emitter signs
* the ANCHOR form of the commit message as soon as the anchor is armed (on 2800 since 13,014,000);
* the verifier checked the ECDSA committed-seal digest. Two copies of one rule, and they drifted. The
* F95 rehearsal could not see it: its kit runs without the anchor armed.
*
* <p>What this proves, with real Falcon-512 signatures and nothing mocked on the cryptographic path:
*
* <ul>
* <li>anchor armed: a seal made exactly as the emitter makes it (over {@code
* PqAnchorProducer.commitSealMessage}) COUNTS through the production message;
* <li>CONTROL NEGATIV: the SAME seal fed to a validator that still verifies over the commit digest
* (the pre-D-311 behaviour, kept by the upstream-shaped constructors) is REFUZAT. This is the
* bug itself, reproduced; if this test ever goes green the fix has been undone somewhere else;
* <li>anchor armed, an emitter that kept signing the digest is REFUZAT by the fixed verifier;
* <li>anchor never armed: the message IS the commit digest, so old and new verifiers agree.
* </ul>
*/
@ExtendWith(MockitoExtension.class)
public class CommitValidatorAnchorFormTest {
private static final int VALIDATOR_COUNT = 3;
private static final long CHAIN_ID = 28001L;
private static final long HEIGHT = 5_000L;
private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, 0);
/** The ECDSA committed-seal digest (round-specific). */
private final Hash commitDigest = Hash.fromHexStringLenient("0x1");
/** The round-independent on-chain hash of the same block (round forced to 0). */
private final Hash onchainHash = Hash.fromHexStringLenient("0x2");
private QbftNodeList validators;
private @Mock QbftBlockCodec qbftBlockCodec;
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
private final Map<Integer, Address> bindings = new HashMap<>();
private final Map<Integer, byte[]> cheiPublice = new HashMap<>();
private final Map<Integer, SealScheme.PrivateHandle> cheiPrivate = new HashMap<>();
private final PqSignerRegistry registry =
new PqSignerRegistry() {
@Override
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
return bindings.get(validatorIndex);
}
@Override
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
return bindings.get(validatorIndex);
}
@Override
public boolean verifyAtHistoric(
final long blockNumber,
final int validatorIndex,
final Bytes message,
final Bytes signature) {
return verifyAtOwnHead(blockNumber, validatorIndex, message, signature);
}
@Override
public boolean verifyAtOwnHead(
final long blockNumber,
final int validatorIndex,
final Bytes message,
final Bytes signature) {
final byte[] pk = cheiPublice.get(validatorIndex);
return pk != null
&& SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray());
}
};
@BeforeEach
public void setup() {
validators = QbftNodeList.createNodes(VALIDATOR_COUNT, qbftBlockCodec);
for (int i = 0; i < VALIDATOR_COUNT; i++) {
final SealScheme.GeneratedPair pereche = SealSchemes.FALCON_512.generate(random);
bindings.put(i, validators.getNode(i).getAddress());
cheiPublice.put(i, pereche.publicRegistryForm());
cheiPrivate.put(i, pereche.privateKey());
}
}
@AfterEach
public void forgetAnchorConfig() {
PqAnchorProducer.useConfigForTesting(null);
}
private void armAnchorAt(final long anchorBlock) {
PqAnchorProducer.useConfigForTesting(
new PqAnchorConfig(
CHAIN_ID, anchorBlock, Map.of(anchorBlock, 0), OptionalInt.empty(), false));
}
/** Exactly what the emitter (QbftRound.pqSealMessageFor) signs at HEIGHT. */
private Bytes32 emitterMessage() {
return PqAnchorProducer.commitSealMessage(HEIGHT, onchainHash.getBytes(), commitDigest);
}
/** The fixed production shape: the verifier is handed the same message the emitter signs. */
private CommitValidator verificatorReparat(final Bytes32 expectedPqSealMessage) {
return new CommitValidator(
validators.getNodeAddresses(),
round,
commitDigest,
commitDigest,
Hash.wrap(expectedPqSealMessage),
new PqCommitEnforcement(HEIGHT, registry));
}
/** The pre-D-311 shape: enforcement verifies over the commit digest, whatever the emitter signed. */
private CommitValidator verificatorVechi() {
return new CommitValidator(
validators.getNodeAddresses(),
round,
commitDigest,
commitDigest,
new PqCommitEnforcement(HEIGHT, registry));
}
private Commit commitSealedOver(final int nod, final Bytes32 message) {
final SECPSignature ecdsa =
validators.getNode(nod).getNodeKey().sign(Bytes32.wrap(commitDigest.getBytes()));
final byte[] sig =
SealSchemes.FALCON_512.sign(cheiPrivate.get(nod), message.toArray()).orElseThrow();
return validators
.getMessageFactory(nod)
.createCommit(round, commitDigest, ecdsa, Optional.of(new FalconSeal(nod, Bytes.wrap(sig))));
}
@Test
public void anchorArmedTheMessageIsTheAnchorFormNotTheDigest() {
armAnchorAt(HEIGHT);
assertThat(PqAnchorProducer.sealMessageIsAnchorForm(HEIGHT)).isTrue();
assertThat(emitterMessage()).isNotEqualTo(Bytes32.wrap(commitDigest.getBytes()));
}
@Test
public void anchorArmedSealMadeLikeTheEmitterCountsThroughTheFixedVerifier() {
armAnchorAt(HEIGHT);
final Bytes32 message = emitterMessage();
assertThat(verificatorReparat(message).validate(commitSealedOver(0, message))).isTrue();
}
@Test
public void controlNegativTheOldVerifierRefusesTheVerySameSeal() {
// This is D-311 itself: the seal the emitter really produces, judged over the commit digest.
// On the testnet this line was the whole chain stopping at the fork height.
armAnchorAt(HEIGHT);
final Bytes32 message = emitterMessage();
assertThat(verificatorVechi().validate(commitSealedOver(0, message))).isFalse();
}
@Test
public void anchorArmedAnEmitterStillSigningTheDigestIsRefused() {
armAnchorAt(HEIGHT);
final Bytes32 sealedOverDigest = Bytes32.wrap(commitDigest.getBytes());
assertThat(verificatorReparat(emitterMessage()).validate(commitSealedOver(1, sealedOverDigest)))
.isFalse();
}
@Test
public void anchorNeverArmedTheMessageIsTheDigestAndBothVerifiersAgree() {
PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID));
final Bytes32 message = emitterMessage();
assertThat(message).isEqualTo(Bytes32.wrap(commitDigest.getBytes()));
assertThat(verificatorReparat(message).validate(commitSealedOver(2, message))).isTrue();
assertThat(verificatorVechi().validate(commitSealedOver(2, message))).isTrue();
}
}

View File

@ -0,0 +1,109 @@
/*
* Copyright contributors to the 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.core.validation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Hash;
import java.util.List;
import java.util.Map;
import java.util.OptionalInt;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* AERE D-311, the PRODUCTION wiring. {@link CommitValidatorAnchorFormTest} proves the verifier accepts
* the emitter's seal when it is handed the emitter's message; this proves that {@link
* MessageValidator.SubsequentMessageValidator}, the only production caller, hands it exactly that
* message: the anchor form over the ROUND-INDEPENDENT on-chain hash (round forced to 0) once the
* anchor is armed, and the plain committed-seal digest before, computed through the one shared helper
* and touching the block interface for round 0 only when the anchor form needs it.
*/
@ExtendWith(MockitoExtension.class)
public class MessageValidatorAnchorFormPlumbingTest {
private static final long CHAIN_ID = 28001L;
private static final long HEIGHT = 7_000L;
private static final int ROUND = 2;
private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, ROUND);
private final Hash proposalHash = Hash.fromHexStringLenient("0x11");
private final Hash commitHash = Hash.fromHexStringLenient("0x22");
private final Hash onchainHash = Hash.fromHexStringLenient("0x33");
private @Mock QbftBlockInterface blockInterface;
private @Mock QbftBlock proposalBlock;
private @Mock QbftBlock commitBlock;
private @Mock QbftBlock roundZeroBlock;
@BeforeEach
public void setup() {
when(proposalBlock.getHash()).thenReturn(proposalHash);
when(blockInterface.replaceRoundForCommitBlock(proposalBlock, ROUND)).thenReturn(commitBlock);
when(commitBlock.getHash()).thenReturn(commitHash);
}
@AfterEach
public void forgetAnchorConfig() {
PqAnchorProducer.useConfigForTesting(null);
}
private MessageValidator.SubsequentMessageValidator build() {
return new MessageValidator.SubsequentMessageValidator(
List.of(Address.fromHexString("0x1")), round, proposalBlock, blockInterface);
}
@Test
public void anchorArmedTheVerifierIsHandedTheAnchorFormOverTheRoundZeroHash() {
PqAnchorProducer.useConfigForTesting(
new PqAnchorConfig(CHAIN_ID, HEIGHT, Map.of(HEIGHT, 0), OptionalInt.empty(), false));
when(blockInterface.replaceRoundForCommitBlock(proposalBlock, 0)).thenReturn(roundZeroBlock);
when(roundZeroBlock.getHash()).thenReturn(onchainHash);
final Hash handed = build().commitValidatorForTesting().expectedPqSealMessageForTesting();
assertThat(handed)
.isEqualTo(Hash.wrap(PqAnchor.commitMessage(CHAIN_ID, HEIGHT, onchainHash.getBytes())));
assertThat(handed).isNotEqualTo(commitHash);
verify(blockInterface).replaceRoundForCommitBlock(proposalBlock, 0);
}
@Test
public void anchorNeverArmedTheVerifierIsHandedTheCommitDigestAndRoundZeroIsNeverAsked() {
PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID));
final Hash handed = build().commitValidatorForTesting().expectedPqSealMessageForTesting();
assertThat(handed).isEqualTo(commitHash);
verify(blockInterface, never()).replaceRoundForCommitBlock(any(), eq(0));
}
}

View File

@ -111,7 +111,8 @@ public class PqHybridEnforcementTest {
}
private PqCommitEnforcement hibrid() {
return new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry);
// every height counts as an anchor parent here, so the refusals below keep their meaning
return new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry, h -> true);
}
private FalconSeal sigiliuFalcon(final int index) {
@ -292,4 +293,20 @@ public class PqHybridEnforcementTest {
assertThat(registry.address(0)).isPresent();
assertThat(Map.of()).isEmpty();
}
/** D-329 (2026-09-03): off the anchor parents the extra seals are neither demanded nor emitted. */
@Test
void offTheAnchorParentsACommitWithoutExtrasStillCounts() {
final PqCommitEnforcement pe =
new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry, h -> false);
final FalconSeal falcon = sigiliuFalcon(0);
assertThat(pe.refusal(H_HIBRID, VALIDATOR_0, DIGEST, java.util.Optional.of(falcon), java.util.List.of()))
.describedAs("no extras demanded where the successor is not an anchor")
.isEmpty();
final PqCommitEnforcement peOn =
new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry, h -> true);
assertThat(peOn.refusal(H_HIBRID, VALIDATOR_0, DIGEST, java.util.Optional.of(falcon), java.util.List.of()))
.describedAs("the same commit IS refused where the successor is an anchor")
.isPresent();
}
}

View File

@ -26,6 +26,8 @@ import static org.hyperledger.besu.consensus.common.bft.Vote.DROP_BYTE_VALUE;
import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.PqAnchorV2;
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
import org.hyperledger.besu.consensus.common.bft.Vote;
import org.hyperledger.besu.consensus.common.validator.VoteType;
import org.hyperledger.besu.crypto.SECPSignature;
@ -123,9 +125,32 @@ public class QbftExtraDataCodec extends BftExtraDataCodec {
// Backward-tolerant: older/exclude/no-certificate encodings without this element decode to an
// empty list.
final List<FalconSeal> falconSeals;
final List<SchemeSeal> hybridSeals;
if (!rlpInput.isEndOfCurrentList()) {
// AERE ANCHOR V2 (2026-09-03): the sixth element is EITHER the v1 Falcon list
// RLP[[idx,sig],...] OR the scheme-tagged v2 certificate RLP[2,[[scheme,idx,sig],...]].
// They cannot be confused: v1 opens with a LIST, v2 with the SCALAR 2. The raw bytes are
// taken first so the v2 decoder can enforce its canonical round trip on exactly what the
// header carried; the codec's own round trip (gate 2 below) then covers both forms.
final Bytes certificateRaw = rlpInput.readAsRlp().raw();
final RLPInput probe = new BytesValueRLPInput(certificateRaw, false);
probe.enterList();
final boolean v2 = !probe.isEndOfCurrentList() && !probe.nextIsList();
if (v2) {
falconSeals = Collections.emptyList();
try {
hybridSeals = PqAnchorV2.decode(certificateRaw);
} catch (final RuntimeException e) {
throw new RLPException(
"AERE ANCHOR V2: the header's certificate does not decode as a canonical v2"
+ " certificate: "
+ e.getMessage());
}
} else {
hybridSeals = Collections.emptyList();
falconSeals =
rlpInput.readList(
new BytesValueRLPInput(certificateRaw, false)
.readList(
rlp -> {
rlp.enterList();
final int idx = rlp.readIntScalar();
@ -133,8 +158,10 @@ public class QbftExtraDataCodec extends BftExtraDataCodec {
rlp.leaveList();
return new FalconSeal(idx, sig);
});
}
} else {
falconSeals = Collections.emptyList();
hybridSeals = Collections.emptyList();
}
// AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 1 of 2: the leave is STRICT.
@ -150,7 +177,7 @@ public class QbftExtraDataCodec extends BftExtraDataCodec {
rlpInput.leaveList();
final BftExtraData decoded =
new BftExtraData(vanityData, seals, vote, round, validators, falconSeals);
new BftExtraData(vanityData, seals, vote, round, validators, falconSeals, hybridSeals);
// AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 2 of 2: CANONICAL ROUND TRIP.
//
@ -204,7 +231,10 @@ public class QbftExtraDataCodec extends BftExtraDataCodec {
// committed-seal hash and the on-chain block hash are byte-identical to upstream Besu, and a
// header with no certificate is byte-identical to upstream too, so the Falcon seals are
// never part of any signed pre-image and never disturb the block hash.
if (!bftExtraData.getFalconSeals().isEmpty()) {
if (!bftExtraData.getHybridSeals().isEmpty()) {
// AERE ANCHOR V2: the scheme-tagged certificate, canonical bytes, in the same slot.
encoder.writeRaw(PqAnchorV2.encode(bftExtraData.getHybridSeals()));
} else if (!bftExtraData.getFalconSeals().isEmpty()) {
encoder.writeList(
bftExtraData.getFalconSeals(),
(falconSeal, rlp) -> {

View File

@ -118,7 +118,10 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
initialExtraData.getVote(),
roundNumber,
initialExtraData.getValidators(),
initialExtraData.getFalconSeals());
initialExtraData.getFalconSeals(),
// AERE ANCHOR V2 (2026-09-03, D-328): the sealed block keeps the proposer's v2 certificate
// verbatim, exactly as it keeps the v1 one; dropping it here is what stalled the testnet.
initialExtraData.getHybridSeals());
final BlockHeader anchoredHeader =
BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader))
.extraData(bftExtraDataCodec.encode(anchoredExtraData))

View File

@ -20,6 +20,8 @@ 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.PqAnchorV2;
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
import org.hyperledger.besu.ethereum.core.BlockHeader;
import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule;
@ -152,6 +154,20 @@ public class PqAnchorDigestRule implements DetachedBlockHeaderValidationRule {
return false;
}
// AERE ANCHOR V2 (2026-09-03): from the v2 height the ONLY accepted form is the scheme-tagged
// certificate under the v2 digest; below it a v2 certificate is refused. Both directions
// are checked so the switch is a clean fork and never a header two nodes read differently.
if (config.anchorV2AppliesAt(number)) {
return judgeV2(number, header, extraData, vanity);
}
if (!extraData.getHybridSeals().isEmpty()) {
LOG.warn(
"AERE PQ ANCHOR (R1): block {} REJECTED - carries a v2 (scheme-tagged) certificate below"
+ " the v2 activation height {}.",
number,
config.anchorV2Block());
return false;
}
final List<FalconSeal> certificate = List.copyOf(extraData.getFalconSeals());
// Height aware since 2026-08-11. Inside the one measured historical window the ordering
// requirement relaxes from SORTED to DISTINCT, because 36 canonical headers were written
@ -203,6 +219,36 @@ public class PqAnchorDigestRule implements DetachedBlockHeaderValidationRule {
}
}
/** AERE ANCHOR V2: vanityData must be the v2 digest over the carried scheme-tagged certificate. */
private boolean judgeV2(
final long number, final BlockHeader header, final BftExtraData extraData, final Bytes vanity) {
final List<SchemeSeal> seals = extraData.getHybridSeals();
if (seals.isEmpty()) {
LOG.warn(
"AERE PQ ANCHOR (R1): block {} REJECTED - at or above the v2 activation height {} the header"
+ " carries no scheme-tagged certificate ({} v1 Falcon seal(s) instead).",
number,
config.anchorV2Block(),
extraData.getFalconSeals().size());
return false;
}
final Bytes32 expected =
PqAnchorV2.anchorDigestV2(
config.chainId(), number - 1L, header.getParentHash().getBytes(), seals);
if (!expected.equals(Bytes32.wrap(vanity))) {
LOG.warn(
"AERE PQ ANCHOR (R1): block {} REJECTED - v2 anchor digest mismatch. vanityData carries {}"
+ " but the {}-seal scheme-tagged certificate actually carried digests to {}. The"
+ " certificate was stripped, replaced or reordered after the proposer wrote it.",
number,
vanity,
seals.size(),
expected);
return false;
}
return true;
}
/**
* Accept a header from a named unenforced window as it was written, after the one check that is
* not relaxed at any height.

View File

@ -18,18 +18,28 @@ import org.hyperledger.besu.consensus.common.bft.BftContext;
import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
import org.hyperledger.besu.consensus.common.bft.HybridSealSupport;
import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry;
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.PqAnchorV2;
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
import org.hyperledger.besu.consensus.common.bft.SealScheme;
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.ProtocolContext;
import org.hyperledger.besu.ethereum.core.BlockHeader;
import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.List;
import java.util.OptionalInt;
import java.util.Set;
@ -171,6 +181,18 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule {
observeValidatorsForDiagnostics(parent, protocolContext);
final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData());
// AERE ANCHOR V2 (2026-09-03): one form per height, both directions refused (see R1).
if (config.anchorV2AppliesAt(number)) {
return judgeV2(number, parent, extraData, protocolContext);
}
if (!extraData.getHybridSeals().isEmpty()) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - carries a v2 (scheme-tagged) certificate below"
+ " the v2 activation height {}.",
number,
config.anchorV2Block());
return false;
}
final List<FalconSeal> certificate = List.copyOf(extraData.getFalconSeals());
final int k = certificate.size();
@ -244,6 +266,51 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule {
PqAnchor.commitMessage(
config.chainId(), parent.getNumber(), parent.getHash().getBytes());
if (!falconSealsVerify(number, parent, eligible, message, certificate)) {
return false;
}
LOG.debug(
"AERE PQ ANCHOR (R2): block {} accepted with {} verified Falcon seal(s) over parent {} "
+ "(threshold K={}, |parent validators|={}).",
number,
k,
parent.getNumber(),
required,
eligible.size());
return true;
} catch (final Exception e) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the certificate could not be checked: {}",
number,
e.toString());
return false;
}
}
/**
* Accept a header from a named unenforced window as it was written, after the one check that is
* not relaxed at any height.
*
* <p>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
*/
/**
* The Falcon half, shared by the v1 and the v2 form: every seal from an index the registry binds
* to a validator of the parent, no two indices on one address, each verifying over M(parent).
*/
private boolean falconSealsVerify(
final long number,
final BlockHeader parent,
final Set<Address> eligible,
final Bytes32 message,
final List<FalconSeal> certificate) {
final Set<Address> counted = new HashSet<>();
for (final FalconSeal seal : certificate) {
final int index = seal.getValidatorIndex();
@ -296,39 +363,149 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule {
return false;
}
}
LOG.debug(
"AERE PQ ANCHOR (R2): block {} accepted with {} verified Falcon seal(s) over parent {} "
+ "(threshold K={}, |parent validators|={}).",
number,
k,
parent.getNumber(),
required,
eligible.size());
return true;
} catch (final Exception e) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the certificate could not be checked: {}",
number,
e.toString());
return false;
}
}
/**
* Accept a header from a named unenforced window as it was written, after the one check that is
* not relaxed at any height.
*
* <p>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
* AERE ANCHOR V2: the scheme-tagged certificate. K Falcon seals verified exactly as in v1, and for
* EVERY extra scheme the scheme schedule names at the parent height, K seals that (a) come from an
* index also carrying a Falcon seal in this certificate and (b) verify under the hybrid registry's
* key for that index and scheme over the same M(parent). A scheme the schedule does not name is
* refused, a missing registry is refused: a node that cannot verify has not proved the header good.
*/
private boolean judgeV2(
final long number,
final BlockHeader parent,
final BftExtraData extraData,
final ProtocolContext protocolContext) {
final List<SchemeSeal> seals = extraData.getHybridSeals();
if (seals.isEmpty()) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - at or above the v2 activation height {} the header"
+ " carries no scheme-tagged certificate.",
number,
config.anchorV2Block());
return false;
}
final int required = config.minSealsAt(number);
final List<FalconSeal> falcon = new ArrayList<>();
final Set<Integer> falconIndices = new HashSet<>();
for (final SchemeSeal s : seals) {
if (s.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) {
falcon.add(new FalconSeal(s.getValidatorIndex(), s.getSignature()));
falconIndices.add(s.getValidatorIndex());
}
}
if (falcon.size() < required) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate has {} Falcon seal(s), below"
+ " the threshold K={} in force at this height.",
number,
falcon.size(),
required);
return false;
}
final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class);
final Collection<Address> parentValidators =
bftContext.getValidatorProvider().getValidatorsForBlock(parent);
if (parentValidators == null || parentValidators.isEmpty()) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the validator set of the parent block {} could"
+ " not be resolved, so seal eligibility cannot be decided.",
number,
parent.getNumber());
return false;
}
final Set<Address> eligible = new HashSet<>(parentValidators);
final Bytes32 message =
PqAnchor.commitMessage(config.chainId(), parent.getNumber(), parent.getHash().getBytes());
if (!falcon.isEmpty() && !falconSealsVerify(number, parent, eligible, message, falcon)) {
return false;
}
final HybridSealSupport hybrid = HybridSealSupport.instance();
final Set<String> requiredSchemes = new HashSet<>();
hybrid.schedule().ifPresent(sch -> requiredSchemes.addAll(sch.schemesAt(parent.getNumber())));
requiredSchemes.remove(SealSchemes.FALCON_512.id());
final Map<String, Integer> counted = new HashMap<>();
for (final SchemeSeal s : seals) {
if (s.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) {
continue;
}
final Optional<SealScheme> scheme = SealSchemes.byWireId(s.getSchemeWireId());
if (scheme.isEmpty() || !requiredSchemes.contains(scheme.get().id())) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate carries a seal of scheme 0x{},"
+ " which the scheme schedule does not name at the parent height {} (named: {}).",
number,
Integer.toHexString(s.getSchemeWireId() & 0xff),
parent.getNumber(),
requiredSchemes);
return false;
}
if (!falconIndices.contains(s.getValidatorIndex())) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - a {} seal from index {} is not bound to a Falcon"
+ " seal of the same index in this certificate.",
number,
scheme.get().id(),
s.getValidatorIndex());
return false;
}
final Optional<HybridSignerRegistry> registry = hybrid.registry();
if (registry.isEmpty()) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the certificate carries {} seals and this node"
+ " has NO hybrid registry loaded, so it cannot verify them. A node that cannot"
+ " verify has not proved the header good.",
number,
scheme.get().id());
return false;
}
final Optional<byte[]> pk = registry.get().publicKey(s.getValidatorIndex(), scheme.get().id());
if (pk.isEmpty()) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the hybrid registry holds no {} key for index {}.",
number,
scheme.get().id(),
s.getValidatorIndex());
return false;
}
if (!scheme.get().verifyRaw(pk.get(), message.toArray(), s.getSignature().toArray())) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the {} signature from index {} does not verify"
+ " over M(parent {}).",
number,
scheme.get().id(),
s.getValidatorIndex(),
parent.getNumber());
return false;
}
counted.merge(scheme.get().id(), 1, Integer::sum);
}
for (final String schemeId : requiredSchemes) {
final int have = counted.getOrDefault(schemeId, 0);
if (have < required) {
LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate has {} valid {} seal(s), below"
+ " the threshold K={} the schedule demands of every named scheme.",
number,
have,
schemeId,
required);
return false;
}
}
LOG.debug(
"AERE PQ ANCHOR (R2): block {} accepted with a {}-seal scheme-tagged certificate over parent"
+ " {} (K={} per scheme, schemes={}).",
number,
seals.size(),
parent.getNumber(),
required,
requiredSchemes);
return true;
}
private boolean acceptAsWrittenInsideLapse(final BlockHeader header, final long number) {
try {
final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData());

View File

@ -0,0 +1,361 @@
/*
* Copyright contributors to 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.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID;
import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H;
import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS;
import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.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.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.HybridSealSupport;
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
import org.hyperledger.besu.consensus.common.bft.PqAnchorV2;
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
import org.hyperledger.besu.consensus.common.bft.SealScheme;
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
import org.hyperledger.besu.crypto.SecureRandomProvider;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.ProtocolContext;
import org.hyperledger.besu.ethereum.core.BlockHeader;
import org.hyperledger.besu.ethereum.rlp.RLPException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import org.apache.tuweni.bytes.Bytes;
import org.apache.tuweni.bytes.Bytes32;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.quality.Strictness;
/**
* AERE ANCHOR V2 (2026-09-03): the two header rules judge the SCHEME-TAGGED certificate, and the codec
* carries it in the sixth extraData element beside the v1 form.
*
* <p>Falcon seals come from a fake registry (a seal is valid iff it is exactly what the fake signs for
* that index over M(parent)); SLH-DSA seals are REAL, from probe keys generated here and published in
* a hybrid-1 registry loaded through the real system-configuration path.
*
* <p>Proven, each with its pair: R1 accepts the honest v2 header and refuses a stripped certificate
* (digest mismatch), a v2 certificate below the v2 height, and a v1 certificate at the v2 height; R2
* accepts the honest v2 header and refuses an SLH-DSA seal from an index without a Falcon seal, a
* certificate short of one scheme, a tampered SLH-DSA signature, and a scheme the schedule does not
* name; the codec round-trips both forms and refuses a non-canonical v2 certificate.
*/
public class PqAnchorV2RulesTest {
private static final long V2 = H + 100L;
private static final int K = 3;
private static final String SLH = "slh-dsa-sha2-128s";
private static final QbftExtraDataCodec CODEC = new QbftExtraDataCodec();
@TempDir Path tmp;
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
private final List<SealScheme.GeneratedPair> slhKeys = new ArrayList<>();
private final FakeFalconRegistry falcon = new FakeFalconRegistry();
private PqAnchorConfig armedV2;
private PqAnchorDigestRule r1;
private PqAnchorSealsRule r2;
@BeforeEach
void setUp() throws Exception {
for (int i = 0; i < VALIDATORS.size(); i++) {
slhKeys.add(SealSchemes.SLH_DSA_128S.generate(random));
}
armHybrid("0:falcon-512+" + SLH);
armedV2 = new PqAnchorConfig(
CHAIN_ID, H, Map.of(H, 0, V2, K), OptionalInt.empty(), false, OptionalInt.empty(),
OptionalInt.empty(), V2);
r1 = new PqAnchorDigestRule(armedV2);
r2 = new PqAnchorSealsRule(armedV2, falcon);
}
@AfterEach
void tearDown() {
System.clearProperty(HybridSealSupport.PROPERTY_SCHEDULE);
System.clearProperty(HybridSealSupport.PROPERTY_REGISTRY);
HybridSealSupport.resetForTesting();
}
private void armHybrid(final String schedule) throws Exception {
final StringBuilder p = new StringBuilder();
p.append("formatVersion=hybrid-1\nchainId=").append(CHAIN_ID).append("\ncount=")
.append(VALIDATORS.size()).append('\n');
for (int i = 0; i < VALIDATORS.size(); i++) {
p.append(i).append(".addr=").append(VALIDATORS.get(i).toHexString()).append('\n');
p.append(i).append(".key.").append(SLH).append('=')
.append(Bytes.wrap(slhKeys.get(i).publicRegistryForm()).toHexString()).append('\n');
}
final Path reg = tmp.resolve("hybrid-" + schedule.replaceAll("[^a-z0-9]", "-") + ".properties");
Files.writeString(reg, p.toString());
System.setProperty(HybridSealSupport.PROPERTY_SCHEDULE, schedule);
System.setProperty(HybridSealSupport.PROPERTY_REGISTRY, reg.toAbsolutePath().toString());
HybridSealSupport.resetForTesting();
assertThat(HybridSealSupport.instance().registry()).isPresent();
}
// ------------------------------------------------------------------------------ certificates
/** Falcon seals for indices 0..falconCount-1 and SLH-DSA seals for 0..slhCount-1, canonical. */
private List<SchemeSeal> certificate(final BlockHeader parent, final int falconCount, final int slhCount) {
final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
final List<SchemeSeal> out = new ArrayList<>();
for (int i = 0; i < falconCount; i++) {
out.add(new SchemeSeal(SealSchemes.FALCON_512.wireId(), i, FakeFalconRegistry.sign(i, m)));
}
for (int i = 0; i < slhCount; i++) {
out.add(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), i,
Bytes.wrap(SealSchemes.SLH_DSA_128S.sign(slhKeys.get(i).privateKey(), m.toArray()).orElseThrow())));
}
out.sort(PqAnchorV2.CANONICAL);
return out;
}
private static Bytes extraDataV2(final Bytes vanity, final List<SchemeSeal> tagged) {
return CODEC.encode(new BftExtraData(
vanity, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, Collections.emptyList(), tagged));
}
private static BlockHeader honestV2(final long number, final BlockHeader parent, final List<SchemeSeal> tagged) {
final Bytes32 d = PqAnchorV2.anchorDigestV2(CHAIN_ID, number - 1L, parent.getHash().getBytes(), tagged);
return header(number, parent.getHash(), extraDataV2(d, tagged));
}
// --------------------------------------------------------------------------------------- R1
@Test
public void r1AcceptsTheHonestV2HeaderAndRefusesAStrippedCertificate() {
final BlockHeader parent = parentHeader(V2 + 4L);
final List<SchemeSeal> tagged = certificate(parent, 5, 5);
final BlockHeader honest = honestV2(V2 + 5L, parent, tagged);
assertThat(r1.validate(honest, parent)).isTrue();
// one SLH-DSA seal stripped under the ORIGINAL digest
final List<SchemeSeal> stripped = new ArrayList<>(tagged);
stripped.removeIf(s -> s.getSchemeWireId() == SealSchemes.SLH_DSA_128S.wireId() && s.getValidatorIndex() == 4);
final BftExtraData decoded = CODEC.decodeRaw(honest.getExtraData());
final BlockHeader strippedHeader =
header(V2 + 5L, parent.getHash(), extraDataV2(decoded.getVanityData(), stripped));
assertThat(r1.validate(strippedHeader, parent)).isFalse();
}
@Test
public void r1RefusesAV2CertificateBelowTheV2HeightAndAV1CertificateAtIt() {
// v2 below V2: the anchor height V2-1 (interval absent => every height from H is an anchor height)
final BlockHeader parentBelow = parentHeader(V2 - 2L);
final BlockHeader v2Early = honestV2(V2 - 1L, parentBelow, certificate(parentBelow, 5, 5));
assertThat(r1.validate(v2Early, parentBelow)).isFalse();
// v1 at V2: an honest v1 header (correct v1 digest) is still refused, the form is wrong
final BlockHeader parent = parentHeader(V2 + 4L);
final BlockHeader v1Late = PqAnchorTestSupport.honestHeader(V2 + 5L, parent.getHash(),
List.of(PqAnchorTestSupport.seal(0), PqAnchorTestSupport.seal(1), PqAnchorTestSupport.seal(2)));
assertThat(r1.validate(v1Late, parent)).isFalse();
// control: the same v1 header is accepted by a rule whose V2 lies in the future
final PqAnchorConfig v1World = new PqAnchorConfig(
CHAIN_ID, H, Map.of(H, 0, V2, K), OptionalInt.empty(), false, OptionalInt.empty(),
OptionalInt.empty(), V2 + 1_000L);
assertThat(new PqAnchorDigestRule(v1World).validate(v1Late, parent)).isTrue();
}
// --------------------------------------------------------------------------------------- R2
@Test
public void r2AcceptsTheHonestV2HeaderWithKSealsPerScheme() {
final BlockHeader parent = parentHeader(V2 + 4L);
assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K, K)), parent, context())).isTrue();
// more than K of each is fine too: K is a floor
assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, 5, 5)), parent, context())).isTrue();
}
@Test
public void r2RefusesAnSlhDsaSealFromAnIndexWithoutAFalconSeal() {
final BlockHeader parent = parentHeader(V2 + 4L);
final List<SchemeSeal> tagged = certificate(parent, K, K + 1); // SLH-DSA from index K has no Falcon
assertThat(r2.validate(honestV2(V2 + 5L, parent, tagged), parent, context())).isFalse();
}
@Test
public void r2RefusesACertificateShortOfOneScheme() {
final BlockHeader parent = parentHeader(V2 + 4L);
assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, 5, K - 1)), parent, context()))
.describedAs("K Falcon seals and only K-1 SLH-DSA: the schedule demands K of EVERY scheme")
.isFalse();
assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K - 1, K - 1)), parent, context()))
.describedAs("short of Falcon as well")
.isFalse();
}
@Test
public void r2RefusesATamperedSlhDsaSignature() {
final BlockHeader parent = parentHeader(V2 + 4L);
final List<SchemeSeal> tagged = new ArrayList<>(certificate(parent, K, K));
for (int i = 0; i < tagged.size(); i++) {
final SchemeSeal s = tagged.get(i);
if (s.getSchemeWireId() == SealSchemes.SLH_DSA_128S.wireId() && s.getValidatorIndex() == 1) {
final byte[] sig = s.getSignature().toArray();
sig[sig.length / 2] ^= 0x01;
tagged.set(i, new SchemeSeal(s.getSchemeWireId(), s.getValidatorIndex(), Bytes.wrap(sig)));
}
}
assertThat(r2.validate(honestV2(V2 + 5L, parent, tagged), parent, context())).isFalse();
}
@Test
public void r2RefusesASchemeTheScheduleDoesNotName() throws Exception {
armHybrid("0:falcon-512");
final BlockHeader parent = parentHeader(V2 + 4L);
assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K, K)), parent, context()))
.describedAs("SLH-DSA seals carried while the schedule names Falcon only")
.isFalse();
assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K, 0)), parent, context()))
.describedAs("control: a Falcon-only v2 certificate under a Falcon-only schedule")
.isTrue();
}
// ------------------------------------------------------------------------------------ codec
@Test
public void theCodecRoundTripsBothFormsAndRefusesANonCanonicalV2Certificate() {
final BlockHeader parent = parentHeader(V2 + 4L);
final List<SchemeSeal> tagged = certificate(parent, 2, 2);
final Bytes v2 = extraDataV2(Bytes32.ZERO, tagged);
final BftExtraData back = CODEC.decodeRaw(v2);
assertThat(back.getHybridSeals()).isEqualTo(tagged);
assertThat(back.getFalconSeals()).isEmpty();
// the v1 form still round-trips through the same slot
final List<FalconSeal> v1 = List.of(PqAnchorTestSupport.seal(0), PqAnchorTestSupport.seal(1));
final BftExtraData backV1 = CODEC.decodeRaw(PqAnchorTestSupport.extraData(Bytes32.ZERO, v1));
assertThat(backV1.getFalconSeals()).containsExactlyElementsOf(v1);
assertThat(backV1.getHybridSeals()).isEmpty();
// a v2 certificate written out of canonical order is refused at decode, not silently reordered
final List<SchemeSeal> unsorted = new ArrayList<>(tagged);
Collections.reverse(unsorted);
// the holder accepts any list; it is the ENCODER that refuses, so no header can carry it
assertThatThrownBy(() -> CODEC.encode(new BftExtraData(
Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, VALIDATORS,
Collections.emptyList(), unsorted)))
.isInstanceOf(RuntimeException.class);
// hand-built non-canonical bytes: the same seals, encoded by hand out of order, do not decode
final Bytes canonical = PqAnchorV2.encode(tagged);
final BftExtraData holder = new BftExtraData(
Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, Collections.emptyList(), tagged);
final Bytes good = CODEC.encode(holder);
assertThat(good.toHexString()).contains(canonical.toUnprefixedHexString());
final Bytes swapped = Bytes.fromHexString(good.toHexString().replace(
canonical.toUnprefixedHexString(), PqAnchorV2.encode(List.of(tagged.get(0))).toUnprefixedHexString()));
assertThatThrownBy(() -> CODEC.decodeRaw(swapped)).isInstanceOf(RLPException.class);
}
// ------------------------------------------------------------------- D-328: the round substitution
@Test
public void replacingTheRoundInABlockKeepsTheV2Certificate() {
// 2026-09-03, testnet 28001: every sealed v2 header lost its certificate at replaceRoundInBlock,
// because that copy went through the v1 constructor; R1 then refused block 168032 for 3 hours.
final BlockHeader parent = parentHeader(V2 + 4L);
final List<SchemeSeal> tagged = certificate(parent, K, K);
final BlockHeader honest = honestV2(V2 + 5L, parent, tagged);
final org.hyperledger.besu.ethereum.core.Block block =
new org.hyperledger.besu.ethereum.core.Block(honest, org.hyperledger.besu.ethereum.core.BlockBody.empty());
final org.hyperledger.besu.ethereum.core.Block substituted =
new org.hyperledger.besu.consensus.common.bft.BftBlockInterface(CODEC)
.replaceRoundInBlock(block, 7, org.hyperledger.besu.consensus.common.bft.BftBlockHeaderFunctions.forCommittedSeal(CODEC));
final BftExtraData after = CODEC.decodeRaw(substituted.getHeader().getExtraData());
assertThat(after.getRound()).isEqualTo(7);
assertThat(after.getHybridSeals()).describedAs("the v2 certificate survives the round substitution").isEqualTo(tagged);
assertThat(after.getFalconSeals()).isEmpty();
assertThat(r1.validate(substituted.getHeader(), parent)).describedAs("R1 still binds it").isTrue();
}
// ---------------------------------------------------------------------------------- helpers
private static ProtocolContext context() {
return contextWith(VALIDATORS);
}
private static ProtocolContext contextWith(final Collection<Address> vs) {
final ValidatorProvider validatorProvider =
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(vs);
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(vs);
final BftContext bftContext = mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
when(bftContext.as(any())).thenReturn(bftContext);
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
}
/** Index i is VALIDATORS.get(i); a seal is valid iff it is exactly what {@link #sign} produces. */
private static final class FakeFalconRegistry implements PqSignerRegistry {
static Bytes sign(final int index, final Bytes32 message) {
final byte[] out = new byte[655];
out[0] = (byte) index;
for (int i = 0; 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 validatorIndex >= 0 && validatorIndex < VALIDATORS.size() ? VALIDATORS.get(validatorIndex) : null;
}
@Override
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
return addressForIndexAtHistoric(blockNumber, validatorIndex);
}
@Override
public boolean verifyAtHistoric(
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
return message.size() == 32 && sign(validatorIndex, Bytes32.wrap(message)).equals(signature);
}
@Override
public boolean verifyAtOwnHead(
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
return verifyAtHistoric(blockNumber, validatorIndex, message, signature);
}
}
}