aere-node/RUN-A-NODE.md

44 KiB

Run a node on Aere Network mainnet, chain 2800

This file plus genesis.json plus one bootnode enode (see the bootnodes section below) is everything an outsider needs to bring up a full node that syncs chain 2800 from block 0. Until 2026-08-03 this repository shipped the precompile sources and the NIST vectors but not the chain configuration, so nobody outside the Foundation could actually start a node, and the "verify it yourself" claim the repository is built on could not be exercised end to end.

Nothing here is a key. genesis.json is public chain configuration; a node that nobody can dial is a node nobody can sync from, and the bootnodes section says plainly how dialing works today.


The one command

If you have JDK 21 and git, one command verifies the genesis and the registries by sha256, fetches upstream Besu at the named commit, applies the four mainnet patches in order, builds the node, and starts a full node syncing chain 2800 from block 0:

./run-node.sh --bootnode <enode://...@host:port>

That is the whole setup. The script is deterministic and idempotent: it refuses to run if the genesis or the registries do not match their published sha256, it re-uses a build it already made, and re-running it re-applies nothing and rebuilds nothing. It holds no key and signs nothing. It configures a node that VALIDATES chain 2800, including the post-quantum certificate anchor, but does not PRODUCE seals; producing seals needs a validator Falcon key, which is not in this repository and is not required to follow the chain.

./run-node.sh --help              # --data-path, --p2p-port, --rpc-port, --sync-min-peers, --no-start, --rebuild
./run-node.sh --no-start ...      # verify, patch and build, but do not start the node
./run-node.sh --sync-min-peers 1  # on a young network with few reachable peers (Besu's default is 5)

You still need a bootnode enode for discovery (see the bootnodes section below for how to get one today). Everything except peer discovery works without it; if you omit --bootnode the script says so plainly and stops with instructions rather than starting a node that can never find a peer.

Everything the one command does is also written out by hand below, unchanged, for anyone who wants to audit each step rather than run it.


The five things you need

What Where
genesis.json The genesis file, byte for byte as it sits on the Foundation nodes. sha256 e5038e6a1437bd85fa51294609edf2126a997f14f4fa7a65a0d39bd0aa24d2e5 (of the published file with LF line endings, exactly as curl fetches it; a Windows checkout that converts line endings will hash differently) Updated 2026-09-10: the fleet genesis gained a third config.pqRegistryHash entry at 18,082,816 (block-0 hash unchanged; only config changed), so the file and its digest changed on that date.
registries/ The public Falcon validator registries. Without them a node imports fine to block 13,014,000 (the first anchor) and then refuses every anchor block, because it cannot map a certificate's seal indices to validator addresses. See registries/README.md; sha256 of each below.
a bootnode enode one published in the bootnodes section below; run-node.sh uses it by default
patches/, precompiles/ The Besu fork, see README.md. Needed to sync past block 9,189,161
a clock within one second of real time not a file, but as hard a requirement as the four above. See below

Your clock, and why it is on that list. QBFT header validation on this chain is TimestampBoundedByFutureParameter(1): a header whose timestamp is more than one second ahead of your node's clock is invalid. Blocks here are ~0.6 s apart, so a machine running one second slow sees every fresh head as "from the future" - and Besu does not treat that as a clock disagreement. It treats it as BREACH_OF_PROTOCOL and disconnects the peer that served it:

INFO  | TimestampBoundedByFutureParameter | Invalid block header:
        timestamp 1789189254 is greater than the timestamp margin 1789189253
WARN  | PipelineChainDownloader | Invalid block detected (BREACH_OF_PROTOCOL).
        Disconnecting from sync target.

If you see that, nothing is wrong with the chain and nothing is wrong with this package. Your clock is behind. The node recovers on its own ("Chain download failed. Restarting after short delay.") and will still reach the head, but it drops good peers while it does. Run NTP - and check it actually took: measured 2026-09-12 on our own import run, a machine where timedatectl reported NTPSynchronized=yes was still a full second behind, because it was a VM whose host clock was right and whose own was not. Compare against the chain itself rather than against a status flag:

curl -s -X POST -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["latest",false]}' \
  https://rpc.aere.network | grep -o '"timestamp":"0x[0-9a-f]*"'

Convert that hex to decimal and compare it with date +%s on your machine. A difference of 0 or 1 is fine. More than that, in either direction, and you will see the behaviour above.

The registries are public (Falcon public keys and validator addresses, each carrying a proof of possession and an ECDSA claim; no private key material). Their sha256, also in registries/SHA256SUMS:

006b1b3fb814ed84622539160956263faca7c0b76585c22f2e8acb926c0bcb7f  manifest-13014000.json
23736b7d4ba1d3213496f8b5a5f756d5c958b08efebdeb1329b24d7dfa45fb6e  manifest-13600000.json
c6601ae5159875a090066946991517c9a38e5d6206da76b018c0917e1ca6f12f  manifest-18082816.json
35d69f60e357d405aff39fd33858de901dfb21327b0c4aeb18be17ffdaa115d6  hibrid-10.properties
c9a215d294e106f69fb2f16e3af21f9787523969e4b7260a3098f4b2cf458059  registru-2800-v2-13600000.properties

Which Besu

Stock upstream Besu syncs chain 2800 correctly up to block 9,189,161 and then stops being able to follow it. That block is the first with a timestamp at or after activationTime 1783820272, when the five Aere post-quantum precompiles come into existence. A node without them computes a different result for any block containing a call to 0x0AE1 through 0x0AE5 and falls off the chain.

So there are two honest paths:

  • Follow the tip. Build the fork: apply patches/0001-aere-pqc-precompiles-mainnet.patch to upstream Besu commit d2032017bb3b8cb215a97303980a1e4a643f7180 and build it. Full instructions, including the pre-image hashes that prove you are on the right base, are in README.md.
  • Verify the early chain only. Stock Besu 26.4 or later will import blocks 0 through 9,189,160 from this genesis. That is enough to check the genesis allocation and the sub-second block period transition against what we publish. It is not enough to reach the head.

There is a second Aere change to the client, and it is not in patch 0001: patches/0004-aere-basefee-floor.patch, one file, LondonFeeMarket.java. From block 10,141,734 chain 2800 clamps the EIP-1559 base fee to a floor of 1 Gwei. The patch is fork gated by two JVM system properties and is a byte behaviour no-op when they are unset, so a node that applies it without setting them is stock Besu. Regenerated 2026-09-05 from the production tree: the previous revision of this patch hard-coded both values as constants, so on a node built from this recipe the two properties were dead (the floor was still 1 Gwei from 10,141,734, so the computed base fee was the same, but the sentence above was not true of the code). Found by comparing the fleet's jars with a build of this recipe class by class; the three consensus jars were already identical, this one class was not.

-Daere.basefee.floor.forkBlock=10141734 -Daere.basefee.floor.value=1000000000

Pass those through BESU_OPTS. Apply this patch and set these flags if you want a client that computes what the network computes. It was measured on 2026-08-02 that QBFT and IBFT2 in Besu do not validate the base fee at import at all, upstream or in our fork, so a node without this change is expected to import the chain anyway and only to disagree if it ever proposed a block. That is a finding about a missing upstream rule and not a licence to skip the patch.

Do not apply patches/0002-...-testnet.patch for a mainnet node. It adds 0x0AE6 and 0x0AE7, which are not on mainnet, and a node that has them will not disagree with the chain today but is running a configuration the chain does not.

The post-quantum header anchor: apply patch 0003 to follow the current chain

Since block 13,014,000 every 32nd header of chain 2800 (every 128th from block 17,225,968 to block 20,746,736) carries a sixth element in its QBFT extraData: a certificate of validator post-quantum seals, universal since block 13,889,296, enforced at a minimum of three seals per anchor block (f+1 of nine) from 2026-08-14, of six from block 14,961,456 and of seven, as many as the quorum of the ten validators (the seals prove those validators signed the block, not that it was decided, per the AIP-22 erratum of 2026-10-01), from block 20,715,632, for each scheme once the certificate became hybrid (Falcon-512 and SLH-DSA-SHA2-128s) at block 17,047,600; since block 20,746,736 SLH-DSA seals are required at every 128th block only. A node rejects an anchor block whose certificate holds fewer valid seals than the schedule requires at its height. (Correction 2026-08-19: an earlier version of this paragraph said that from block 14,050,000 the network refuses to finalize any block without a post-quantum quorum; the per-block rule armed at that height is retired in the shipped code in favour of the anchor rules and changed no enforcement, so that sentence is withdrawn.)

The anchor ships in this repository both as source, under anchor/, and as patches/0003-aere-pq-anchor.patch. A node built without it has not been shown to import headers past block 13,014,000: the second client needed an explicit change to accept the sixth element, and no floor-only build has been observed following the chain past that height. Apply 0001, 0003, 0004, 0005, 0006 and 0007 together and treat a build missing any of them as unable to follow the current chain (from 2026-09-22 the last two are part of the recipe; see the section on them below).

Patch 0005 exists because the import proof was actually attempted

Measured 2026-08-15, on a real from-genesis sync of a build from patches 0001+0003+0004 alone: the node followed chain 2800 for 9.18 million blocks and stopped at block 9,189,161, the futureEipsTime activation, with failed persisting block due to stateroot mismatch. Not one cause but two, both in MainnetProtocolSpecs.java, both now carried by patches/0005-aere-eip2935-futureeips.patch: upstream bases the futureEips milestone on the unreleased amsterdam definition (Block Access Lists, a different state-root committer), while chain 2800 isolates it on Osaka; and the fleet activates EIP-2935 historical block hashes in that same fork, one system state write per block from 9,189,161 onward, which a build without the patch never performs. No amount of green unit tests found this; importing the real chain found it in one afternoon.

Measured 2026-08-16: the from-genesis sync reached the live tip. A node built only from these packages imported chain 2800 from block 0 to height 14,088,211 and then followed the head, crossing the 14,050,000 activation height (which, see above, changed no enforcement) with zero anchor rejections. Two more things had to be true for that, both now carried by the patches and both described with their measured boundaries in IMPORT-PROOF-STARE-2026-08-15.md: a historical window (anchor heights 13,267,824 to 13,268,944) in which anchoring was deliberately disarmed fleet-wide, and a recovery window (13,268,976 to 13,890,544) in which proposers legitimately wrote shorter certificates. Inside those windows the header is accepted as carried; outside them nothing changes. The controls run in both directions (641 tests, 0 failures).

Measured 2026-08-15: on a pristine d2032017 checkout, git apply (no flags) accepted 0001, 0003 and 0004 in that order, and ./gradlew --no-daemon installDist -x spotlessJavaCheck -x test exited 0 on JDK 21, producing besu/v26.8-develop-d203201/linux-x86_64/openjdk-java-21. That proves the recipe builds. Measured again on 2026-09-05 with the regenerated 0003 (169 files: 30 upstream files modified, 139 new, among them the 22 of the SLH-DSA engine): on a pristine d2032017 checkout git apply --check accepted 0001, 0003, 0004 and 0005 in the order run-node.sh uses, and the patched tree equals anchor/ file for file. The installDist build and the from-genesis import were NOT repeated that day; see the revision note below. The import proof above, measured 2026-08-16, proves the build follows the live chain from genesis to the tip. The proof is repeated, not assumed: see IMPORT-PROOF-STARE-2026-08-15.md for the measured heights of each run. Revision note, 2026-09-05: the anchor patch and the configuration below were updated for the hybrid certificate and the interval schedule; the from-genesis import proof has NOT yet been repeated against this revision (the last measured run is the one above, on the previous revision). What is measured for this revision is that the same code follows the live tip on the network's reading nodes, and validates every hybrid anchor there, since 2026-09-05. Revision note, 2026-09-10: patches 0001, 0003 and 0004 were regenerated from the production tree over the same file sets (the source files now carry the Apache-2.0 section 4(b) modification notices that production carries; 0003 keeps its 169 files). Measured the same day: a build from this recipe reproduces the fleet's three consensus jars class by class, and LondonFeeMarket and the precompile classes byte for byte. What the recipe still does not ship is the fleet's parallel block processor (a separate package and one hunk in MainnetProtocolSpecs), which does not change consensus; it is listed by the comparison, not hidden.

Patches 0006 and 0007: native post-quantum transactions and verifiable per-block finality (2026-09-22)

Since 2026-09-22 21:45Z every node of chain 2800 that validates or reads blocks runs a distribution that carries two more Aere changes, and the recipe here carries them as patches/0006-aere-pq-transactions.patch (AIP-20) and patches/0007-aere-pq-finality.patch (AIP-21), generated as exact deltas over the tree that 0001+0003+0004+0005 produce.

  • 0006, AIP-20. Transaction type 0x50, authorized by an ML-DSA (FIPS 204) signature, sender derived from the post-quantum public key. Activation is the per-node property aere.pqtx.forkBlock, which run-node.sh now sets to 19,900,000 for chain 2800. Below that height the node behaves exactly as before. From that height a node without this patch, or without the property, rejects the first block that carries such a transaction and stops following the chain. That is the one reason this revision is not optional.
  • 0007, AIP-21. The JSON-RPC method aere_getPqFinality and the on-disk window of Falcon seals behind it. It changes nothing about block validity, so it has no activation height; a follower built with it answers unavailable for every block (it hears no seals; only validators do), which is the honest answer, and the method exists.

Measured 2026-09-22 22:16Z: the six patches applied in order with plain git apply on a pristine d2032017 checkout (187 files changed), ./gradlew --no-daemon installDist -x spotlessJavaCheck -x test exited 0, and the resulting jars were compared class by class, in both directions, with the fleet distribution running on every chain-2800 node (wt3f12ef0d): 3,503 classes in the public build, 3,529 in the fleet; nothing only in the public build; every consensus class and every AIP-20/21 class has identical code (javap -p -c on each pair whose bytes differ). Of the 44 classes whose bytes differ, 38 differ only in their line-number table, because the public patches carry the Apache 4(b) modification notice at the top of each modified upstream file and the fleet build of 22 September does not yet; the other 6 are the same known non-consensus differences the recipe carried before this revision: the Block-STM wiring hunk in MainnetProtocolSpecs (its 26 classes exist only in the fleet, as an optional parallel processor) and five RPC read-path classes (EthGetBalance, EthGetCode, EthGetStorageAt, EthGetTransactionCount, BlockchainQueries: the fleet's fix for latest resolving to a null state during import, 2026-09-10). None of those touches block validity; they are listed here rather than hidden. The from-genesis import proof has NOT been repeated with this revision yet; what is measured is that the same code follows the live tip on the network's own readers since 2026-09-22. The Apache-2.0 section 4(b) statement for the upstream files these patches modify is in each patch message; the in-file "Modifications Copyright" blocks for those files are deliberately not in the delta (it is exact to the fleet sources) and enter the production sources with the next fleet distribution.

Patch 0008: AIP-22 stages 1 to 3 (2026-10-01)

A node built from patches 0001 and 0003 to 0007 stops at block 20,836,227. From block 20,836,228 (AIP-22 stage 3, 2026-09-30) the header of every chain-2800 block names the validators by post-quantum ids from the registry v3 epoch, and such a node refuses the block with Invalid block header: Incorrect validators. Measured: a follower built from patches 0001 and 0003 to 0007 refuses block 20,836,228 with exactly that message.

patches/0008-aere-pq-aip22.patch brings the recipe to the exact source tree of the chain-2800 fleet distribution of 2026-09-29: AIP-22 stages 1 to 3 (and the code of stages 4 and 5, inactive unless their own activation properties are set, which run-node.sh does not do), and the rest of what the fleet runs (see the patch message). run-node.sh applies it, passes the four stage-3 properties (aere.pq.identityBlock, aere.pq.registryV3, aere.pq.registryV3.hashes, aere.pq.registryV3.keysFrom) and verifies the registry v3 epoch file in registries/ against SHA256SUMS. If you built a node before, run-node.sh now rebuilds it: the patch set is fingerprinted, and a binary built from another patch set is no longer reused.

Note added 2026-10-07: one option that patch carries is of use to a follower today without activating any stage, the reference point aere.pq.checkpoint; it is documented below, after the configuration section.

Measured 2026-10-01: patches 0001 and 0003 to 0008 applied with plain git apply on a pristine d2032017 checkout reproduce the patch generator's tree with zero differences; the build has the same 3,554 classes as the fleet distribution, none only on one side, and no class with different code (javap -p -c on the 45 pairs whose bytes differ: they differ only in the line-number table, because of the in-file licence notices). The same comparison on the build without 0008 finds 51 classes missing and 56 with different code, so the comparison can tell them apart. A follower built from this recipe, started on a database that ended at block 20,836,227, imported block 20,836,228 and went on with ten peers and no refusal (20,840,327 within two minutes).

A follower started with run-node.sh logs AERE-PQC-EMG-CEILING-01 at ERROR level for every block it imports: that is the emergency seal-count ceiling the script sets for followers (see the comment above ANCHOR_MIN_SEALS_CEILING in the script and the section on the configuration below), not a refusal. A refusal names the block and the rule that refused it.


Sizing the JVM heap: bigger is not faster

Measured 2026-09-06 on one machine, one database and one chain, syncing chain 2800 from genesis in a Linux VM with 8 GB of RAM. The same flag was measured at four values and at two different heights, because the answer changes with the height:

-Xmx height reached what the JVM did blocks per minute
4500m ~3.5 M 89 OutOfMemoryError, 7.1 GB RSS, import stopped dead 0
3200m ~6 M 24 OOM per 500 log lines, repeated Chain download failed ~1,500
2400m 6-8 M no OOM at all ~82,000
2400m past 9.2 M old generation 99.88% full, 1,409 full GCs, 19 minutes in GC ~640
3400m past 9.7 M zero full GCs, 6 seconds of GC in total ~28,800
2400m ~8.4 M, measured 2026-09-11 old generation 99.97% full, 1,073 full GCs, 26 minutes in GC, 49 OutOfMemoryError ~120
3400m ~8.5 M, measured 2026-09-11 zero full GCs ~9,000

The band moves down as the chain grows, so read the dates in that table. The 2400m row measured on 2026-09-06 held from six to eight million blocks; the same flag on 2026-09-11, at 8.4 million and on a 7.9 GB machine, was already in full-GC collapse, because the storage engine's working set had grown with the chain in between. Do not copy a number out of this table and hope. Use the symptom instead, it is the same on any day: full GCs climbing while the height barely moves means the heap is too SMALL, and OutOfMemoryError in the log means it is too LARGE for what the database also needs beside it.

Two things are going on, and they pull in opposite directions. Heap is not memory: the storage engine keeps its block cache and write buffers OUTSIDE the Java heap, and past six million blocks that off-heap part is larger than the heap itself, so a heap sized to fill the machine leaves nothing for it and the node dies of OutOfMemoryError. But a heap that is too small is just as bad in the other direction: past block 9,189,161, where the post-quantum precompiles go live, blocks get heavier, the old generation fills, and the node spends its time in full GC instead of importing.

How much RAM a from-genesis import needs, measured. The requirement is not flat: it grows with the height, because the storage engine's working set grows with the chain. On the machine above (8 GB for the node), the same import that ran comfortably at three million blocks was, at 12.2 million, using 5.96 GB of resident memory with a 3.4 GB heap, with the old generation 99.8% full and four full GCs every five seconds - and there was no room left to raise the heap without hitting OutOfMemoryError instead. Treat 8 GB as the floor at which a full from-genesis import stops being practical, not as a comfortable size; 16 GB gives the database the room this measurement shows it wants. Following the live tip, once synced, needs far less: the network's own reading nodes do it inside their normal footprint. So do not pick a fraction of RAM and hope. Watch it: jstat -gcutil <pid> 5000 3 prints one line every five seconds. If FGC climbs while the height barely moves, the heap is too SMALL - raise it. If you see OutOfMemoryError in the log, it is too LARGE for what the database also needs - lower it. On 8 GB, the band that worked here was roughly 2.4 GB early and 3.4 GB past nine million blocks.

The configuration a follower passes (BESU_OPTS)

The Aere fork reads its per-node configuration from JVM system properties passed through the BESU_OPTS environment variable, not from the genesis file. run-node.sh sets these for you; this is what it sets, and what a manual operator must export before the besu command below, or the node will refuse the first anchor at block 13,014,000. Five of them are the ones without which a node stops at block 17,047,600 (the first hybrid, v2 certificate) or at the first anchor after 17,225,968 (the interval change): anchorV2Block, schemeSchedule, hybridRegistry (the SLH-DSA-SHA2-128s public keys, registries/hibrid-10.properties since 2026-09-10; hibrid-1.properties before), anchorIntervalSchedule, and the two dated steps in anchorMinSeals (see anchor/README.md, scope item 3).

Correction, 2026-09-06. On 2026-09-05 those five properties were added to THIS DOCUMENT and not to run-node.sh, while the sentence above already claimed the script set them. It did not. A node started with the published script therefore ran the pre-hybrid configuration, and the v2 activation height stayed at Long.MAX_VALUE: our own from-genesis import proof, built from the published revision, synced honestly to block 17,047,400 and then rejected every hybrid anchor 12,352 times (REJECTED - carries a v2 (scheme-tagged) certificate below the v2 activation height 9223372036854775807). Anyone following this package could not pass block 17,047,600. Since 2026-09-06 the block below and the one inside run-node.sh are both GENERATED from the live BESU_OPTS of the network's archive node, which has exactly a follower's role, with the two public reading nodes as witnesses that must agree on the anchor set; a gate refuses any divergence between the chain, the script and this page. Re-measured after the fix on the same node and database: past 17,047,600 with zero rejections.

The set below is the one the network's own archive node runs with, minus the signing keys.

REG=/path/to/aere-node/registries
export BESU_OPTS="\
-Daere.basefee.floor.forkBlock=10141734 \
-Daere.basefee.floor.value=1000000000 \
-Daere.falcon.anchor.address=0xC01Bb2843EFAF92D8389F14aC1dD26dbeAf9144C \
-Daere.falcon.anchor.block=13889290 \
-Daere.falcon.attachBlock=13889296 \
-Daere.falcon.attachInterval=32 \
-Daere.falcon.forkBlock=14050000 \
-Daere.falcon.manifest=$REG/manifest-18082816.json \
-Daere.falcon.minAttachFutureMargin=5 \
-Daere.falcon.registry.history=$REG/manifest-13014000.json,$REG/registru-2800-v2-13600000.properties \
-Daere.falcon.validatorCount=10 \
-Daere.pq.anchorBlock=13014000 \
-Daere.pq.anchorInterval=32 \
-Daere.pq.anchorIntervalSchedule=17225968:128,20746736:32 \
-Daere.pq.anchor.maxSeals=9 \
-Daere.pq.anchorMinSeals=13014000:0,13034000:3,14961456:6,17102384:0,17102416:6,20715632:7 \
-Daere.pq.anchorV2Block=17047600 \
-Daere.pq.chainId=2800 \
-Daere.pq.hybridRegistry=$REG/hibrid-10.properties \
-Daere.pq.identityBlock=20836228 \
-Daere.pq.registryV3.hashes=20836228:0x5c6f09a44f1b6cc0e2264c9beaf4496e5fdd5bf8ee0dc6571fb5616c80a2e900 \
-Daere.pq.registryV3.keysFrom=20836228 \
-Daere.pq.registryV3=$REG/epoca-v3-2800-20836228.json \
-Daere.pq.schemeSchedule=17047568:falcon-512+slh-dsa-sha2-128s,20746736:falcon-512/32+slh-dsa-sha2-128s/128 \
-Daere.pqtx.forkBlock=19900000 \
-Daere.pq.anchor.minSealsCeiling=1"

Why the seal-threshold ceiling, for a from-genesis node

A node syncing from block 0 starts into the GENESIS validator set, which is small (three validators). The startup threshold guard (PqAnchorThresholdGuard) refuses to arm a seal threshold at or above that set's QBFT quorum, because a proposer at that set size could never gather it. The published schedule reaches K=3 (block 13,034,000), K=6 (block 14,961,456) and K=7 (block 20,715,632), all unreachable at N=3, so without aere.pq.anchor.minSealsCeiling a from-genesis node refuses to start with AERE-PQC-THRESHOLD-01. The ceiling lowers the EFFECTIVE threshold to what the genesis set supports (quorum(3) - 1 = 1) WITHOUT editing the published schedule, exactly as that guard's own message instructs (--Xaere-pq-anchor-min-seals-max). It does not weaken the post-quantum binding a follower checks: every anchor must still carry a valid validator Falcon seal that verifies against the registry, and its digest must still bind the certificate under the block hash; only the seal-COUNT floor, which is a proposer-liveness property relative to the current set and not a history-verification property, is relaxed to one. Real anchor blocks carry far more than one seal, so all import cleanly: measured 2026-09-12 by our own second implementation walking the live chain, the certificates carried 14, 16, 18 or 20 seals in total, which is seven to ten per half since the certificate became hybrid on 2026-09-03. A validator, which starts into the full set and proposes, does not set this.

What you will see in the log, and why it is not an incident. With this ceiling set, the node prints a red startup banner (AERE-PQC-EMG-BANNER-01, "starting with 1 POST-QUANTUM SAFETY CONTROL lowered") and then one ERROR line per imported block (measured 2026-10-01 on a build with patch 0008: 148,076 such lines for 148,076 consecutive heights; before patch 0008 it was one line per anchor block):

ERROR | PqEmergencyShoutRule | AERE PQC EMERGENCY [AERE-PQC-EMG-CEILING-01]: height N validated
WITH A LOWERED SEAL THRESHOLD. The schedule in force asks for K=...

That is the binary doing what we asked it to do: a safety control that has been lowered must be impossible to forget, so it shouts on every use rather than once at startup. On a from-genesis sync it is loud - measured on our own import run, 2,786 such lines in three megabytes of log. It is not a chain fault and not a failed verification.

What WOULD be a fault, and what you should watch for instead: an anchor refused. Those read IneligibleSeal, SealCountBelowThreshold, or AnchorDigestMismatch and stop the import. If you prefer a quiet log once you are past the early heights, restart the node without aere.pq.anchor.minSealsCeiling after it is synced beyond block 13,034,000: by then the live validator set is large enough for the scheduled threshold to arm, and the shouting stops because the control is no longer lowered.

This is the FOLLOWER subset, and the distinction is in the code under anchor/, not guessed. A validator additionally passes aere.falcon.key (the signing key), the aere.falcon.attach* and aere.falcon.validatorCount properties (these drive seal PRODUCTION only), and aere.falcon.forkBlock (a per-block Falcon rule the shipped code retires at the anchor block, so it is inert; leaving it unset keeps the non-blocking baseline). What is above is exactly what the IMPORT path reads: the registries (aere.falcon.manifest loads only together with aere.falcon.anchor.address, so those two are registry wiring here, not attach knobs; aere.falcon.registry.history carries every earlier registry the chain was signed against), the anchor-validation schedule (aere.pq.*), and the 1 Gwei base-fee floor live from block 10,141,734.

A reference point for a node that syncs from genesis: aere.pq.checkpoint (note added 2026-10-07)

The configuration above gives a from-genesis node no knowledge of the chain beyond genesis.json. That is enough to verify every certificate it is served, and not enough to know that it was served the whole chain: a neighbour that controls what you see can hand you a history that stops before the first enforced anchor, or a tail of up to 32 blocks above the latest anchor's parent (33 counting the parent itself, in the case of the AIP-22 erratum of 2026-10-01), and nothing in the headers tells you that the honest chain is longer. That is the weak subjectivity every finality gadget has, and the answer is the one every such chain gives: give the node one recent anchor from outside the sync, and it refuses any chain that does not run through it. The argument, with the machine-checked model (every history that diverges at or below the reference is refused; without a reference a short divergence below an anchor is accepted, shown as a negative control), is research/rewrite-bound-round-free-seal-2026-10-07.md in the aere-research repository (https://git.aere.network/aere-network/aere-research/src/branch/main/research/rewrite-bound-round-free-seal-2026-10-07.md).

The option is in patch 0008 (PqCheckpointRule, AIP-22 stage 4 code, inert unless set; the form below is the one the patch defines). Append it to BESU_OPTS:

-Daere.pq.checkpoint=<height>:<block hash>[,<height>:<block hash>...]

for example -Daere.pq.checkpoint=20966864:0x<64 hex characters>, the height and the 32-byte hash of an anchor block. What it does: a header at a named height whose hash is not the named one is refused at import (AERE-PQC-CHECKPOINT-02 in the log, "a chain that does not run through the checkpoint is never adopted"), and every other height is not judged by it. Unset or blank: inert, every header passes; run-node.sh does not set it, and this document did not name it before today. A value the parser cannot read (no :, a height below 1, a hash that is not 32 bytes, the same height named twice) refuses at startup with AERE-PQC-CHECKPOINT-01; it never degrades to "no checkpoint". It is a separate rule from the anchor configuration, so naming it alone does not arm the anchor loader.

When to use it: a node that syncs from genesis with no other knowledge of the chain, or any node whose peers you do not choose. A node already at the tip has its own head to compare against and gains less.

How to choose the value, and the one thing not to do:

  • pick a recent ANCHOR height (on chain 2800 every 32nd block since block 20,746,736, that is h mod 32 == 16; the dated interval schedule is in SPEC.md 3.1), so that the reference also pins a certificate; the parser accepts any height, and a non-anchor height pins only a hash;
  • take the hash from somewhere the adversary of the argument does not control: the public verifier (tools/verify-anchor.mjs, run from an empty directory as the documentation page "Verify an anchor" gives it) names the anchor heights it verified, and eth_getBlockByNumber on a node you trust, or on the two public endpoints compared against each other (the call is in "Check that you are on the right chain" below), gives the block hash at such a height; a hash handed to you by the same peer that serves you the blocks is no reference at all;
  • never guess or copy a stale value: a wrong hash at a named height makes the node refuse the honest chain at that height and stop there, which is the rule doing exactly what it is for.

What is and is not measured: the rule's own tests in the patch (PqCheckpointRuleTest) cover the refusal at the named height, the pass at every other height, the inert unset form and five malformed values; the model named above proves the reference-point property and runs its negative control. Refusing a served fork at a checkpoint height on a running node has not been exercised on any network, and the option is not part of the follower configuration above [NOT MEASURED].


Start it

Export BESU_OPTS as above first, then:

besu \
  --genesis-file=/path/to/aere-node/genesis.json \
  --data-path=/path/to/data \
  --network-id=2800 \
  --sync-mode=FULL \
  --data-storage-format=BONSAI \
  --bootnodes=<the enode you were given> \
  --p2p-port=30303 \
  --rpc-http-enabled --rpc-http-port=8545 \
  --rpc-http-api=ETH,NET,WEB3,QBFT \
  --min-gas-price=0

One bootnode is published here (two from 2026-08-25 to 2026-09-11; the second address was withdrawn on 2026-09-11 because its host no longer fits the rule in the section below, and a replacement public read host will be published when it exists). It is a public read host that is NOT in the validator set, so taking it down cannot cost the network consensus; its address was already public through the service it serves. It runs Besu:

enode://7e8ff740b79bf28a6d7e46ea8d2317cea4a223e7af60be36e91e13dc7051f4cb29077631452e699a5e18e640a94fd6e370e9e51dd91ecba1f2ba9666e07ff01b@37.27.216.110:30303

run-node.sh uses it by default when --bootnode is not given. A written address is not a live address: only your own net_peerCount proves it is up. If it is unreachable, write to office@aere.network with the subject "bootnode" for a current enode by return.

Measured 2026-09-11: a node that cannot receive inbound connections (behind NAT without a forwarded port) keeps that single peer, and Besu does not fetch the bodies of blocks that carry transactions from a single busy peer: the log repeats Unable to retrieve blocks for block numbers ... Restarting after short delay and the import crawls. Forward your P2P port (30303 by default) and, when you run the manual command below, add --p2p-host=<your public IP> so that peers can bond with you; once discovery works the node reaches the rest of the network on its own (measured the same day with ten peers: about 1,300 blocks per second from genesis, no body failures). With a single published bootnode this matters more, not less.

--sync-mode=FULL is deliberate. Chain 2800 runs QBFT with a half second block period, so the head was about 12.2 million blocks when this was written (2026-08-03), passed 14.6 million on 2026-08-19, and climbs by roughly two per second; read the live height with eth_blockNumber. Full sync from block 0 is the mode that actually verifies the history we publish, which is the point of this repository. Snap sync against a QBFT chain trusts a peer for the state and verifies far less.

--min-gas-price=0 matches the network. Chain 2800 has a real 1 Gwei base fee from block 10,141,734, but the minimum gas price a node will accept into its own pool is a local policy and the Foundation nodes run it at zero.

Outbound and inbound TCP and UDP on your p2p port must be open. Discovery is UDP; a TCP only firewall gives you a node that dials out and is never dialled back.


Check that you are on the right chain, before you trust anything else

Two facts settle it. Run these against your own node once it is up.

1. Your block 0 must be the network's block 0.

curl -s -X POST http://127.0.0.1:8545 -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["0x0",false]}' \
  | grep -o '"hash":"0x[0-9a-f]*"'

Expected, and this is the whole test:

0xd86d57a899cbfa580669f0e1c7cd4ae5f525247c3c823f8f75df176a0c5d7f1a

That hash covers every consensus relevant field in genesis.json, including the state root of the six genesis accounts. If it matches, your genesis file is the network's genesis file and no further comparison of individual fields is needed. If it does not match, your node will never peer, and no amount of correct bootnodes will help.

Compare against the public endpoints, which are separate machines with separate operators of the same file:

curl -s -X POST https://rpc.aere.network  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["0x0",false]}'
curl -s -X POST https://rpc2.aere.network -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["0x0",false]}'

2. You must have peers.

curl -s -X POST http://127.0.0.1:8545 -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"net_peerCount","params":[]}'

A nonzero answer means discovery worked. Zero after a few minutes means either your UDP port is closed or the bootnode you were given is down; a plain TCP connect to its address distinguishes the two.


What the bootnode is, and what it is not

It is a full node that is not in the validator set. Chain 2800 seals blocks with ten QBFT validators (nine from 2026-08-09, seven before; ten since 2026-09-11), and none of them is listed here, on purpose. Publishing a validator's p2p address hands an attacker a target whose downtime costs the network consensus headroom, which is why sentry style architectures exist. The node above carries no consensus responsibility: if it is taken down, new nodes cannot bootstrap until it is replaced, and block production is unaffected.

Update, 2026-08-28. The bootnode (37.27.216.110) now also serves the devp2p snap/1 protocol (SnapServer ... Bonsai full flat db in its startup log, capability list [eth/68..71], [istanbul/100], [snap/1]). A client that chooses --sync-mode=SNAP against it downloads headers plus the state at the pivot instead of replaying every historic block body, which on this chain of empty blocks removes most of the multi-day full-sync cost. Honest limits, measured versus not: the server capability and the public endpoint answering are measured today; a complete stranger's snap sync to head against it has not yet been run, and the one-command run-node.sh path deliberately stays --sync-mode=FULL, because a validating node that replayed every block is a stronger statement than one that trusted a pivot.

A written address is not a live address. This file cannot prove the bootnode is up at the moment you read it; only your own net_peerCount can.

Both were reachable and serving blocks when this file was written. Both are Foundation operated, so this is one operator's word for two addresses. That is a real centralisation fact about chain 2800 today and not a documentation gap.


Measured, 2026-08-03

The recipe on this page was executed rather than written. A Besu node with an empty data directory was started from exactly this genesis.json and exactly these two bootnode URLs, on a machine that is not a validator, with --sync-mode=FULL and no static nodes, no other peer hints and no copied database.

  • Its own block 0 came out as 0xd86d57a899cbfa580669f0e1c7cd4ae5f525247c3c823f8f75df176a0c5d7f1a, which is the live chain's block 0. That hash covers the state root, so the six accounts in alloc are the network's down to the last wei, and so is every header field in the file.
  • It reached 11 peers knowing nothing but the two enode URLs above.
  • It imported from block 0 to block 2,221,580 in about thirty minutes, with zero rejected blocks, which carries it through the sub-second block period change at roughly 2,137,652 and out the other side.

The honest limits on that run, stated because they are the difference between this section and an advertisement:

  • It was stopped at ~2.2 million rather than carried to the head, so it never reached the precompile fork at 9,189,161 or the base fee floor at 10,141,734. "Reaches the current head" is not measured.
  • It ran the Foundation's build of the fork, not a build a third party made from patches/. That those patches build, and that the built precompiles answer the NIST vectors, is measured separately in README.md; that the two together sync to the head is not.
  • Nothing here proves the bootnodes will be up when you read this.

A number in this file that the chain does not agree with

config.transitions.qbft[0].block reads 2138451. That is the height at which the file says the block period drops from one second to half a second. The chain says otherwise. Sampling header timestamps on the public endpoints puts the change just after 2,137,652. Measured spans of twenty blocks: from 2,137,600 the twenty blocks take 20 s, from 2,137,656 they take 15 s, from 2,137,657 they take 12 s, and from 2,137,700 they take a flat 10 s, which is 500 ms a block and stays there through 2,138,400. The published 2138451 is roughly eight hundred blocks late and describes a boundary that is not in the data.

It is published anyway, unaltered, because this file is the file the network runs and its value as evidence comes from being exactly that. Two things make the discrepancy harmless to you rather than merely tolerated: the field only governs how fast a proposer may seal, so it has no effect on a node that follows; and it was measured, on the sync run described above, that a node carrying this exact value imports the blocks either side of both numbers without rejecting one.

Correcting it means editing a file on live validators, which is a change to running consensus infrastructure and is not made as a side effect of publishing a document.

A note on the comment fields in alloc

The six genesis accounts carry comment strings. No client reads them; they are not consensus data and they are inside the file only because they have always been. Two of them are wrong and are left uncorrected here rather than quietly edited, because this file is published byte for byte as the running nodes hold it: the dash characters are mangled, and the 1,400,000,000 AERE account is labelled "Mining Reserve" although chain 2800 has no mining and never has. It is the staking and emissions reserve. Correcting the text means editing a file on live nodes, which is a change to running infrastructure and is not made as a side effect of a documentation fix.