validator/: the operator kit for becoming a validator of chain 2800 (keys generated on the operator's machine, a public candidacy file with proof of possession and a signed clock report, the corrected runbook); run-node.sh, RUN-A-NODE.md and SPEC.md: the follower configuration described as it is (the attach, validatorCount, forkBlock and seal-cap properties it carries are inert on a follower)
This commit is contained in:
parent
3ff53e4c05
commit
f7930b2ff9
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -3,3 +3,4 @@ patches/*.patch -text
|
||||
vectors/** -text
|
||||
registries/*.json -text
|
||||
registries/*.properties -text
|
||||
validator/** text eol=lf
|
||||
|
||||
@ -304,6 +304,10 @@ able to trust without checking.
|
||||
|
||||
## See also (2026-09-02)
|
||||
|
||||
- [`validator/`](validator/README.md) (added 2026-10-20): the operator kit for becoming a validator of chain 2800.
|
||||
Every key is generated on the operator's machine (`validator-keys.sh`); `validator-candidacy.sh` writes the public
|
||||
candidacy file (registry row, enode, proof of possession, signed clock report). Nothing private leaves the machine.
|
||||
|
||||
- [`DEVNET.md`](DEVNET.md): AERE on your machine in one `docker run`, the validators' own binary as a local chain.
|
||||
- Public testnet 28001: `https://testnet-rpc.aere.network`, faucet on `https://aere.network/testnet.html`, an x402 v2
|
||||
facilitator at `https://testnet-rpc.aere.network/x402/` settling a test dollar with EIP-3009 (probed every 6 hours,
|
||||
|
||||
@ -484,12 +484,17 @@ prefer a quiet log once you are past the early heights, restart the node without
|
||||
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
|
||||
This is the configuration of a node that FOLLOWS the chain. *Corrected 2026-10-20:* until this date
|
||||
the paragraph said that a follower leaves out `aere.falcon.attach*`, `aere.falcon.validatorCount` and
|
||||
`aere.falcon.forkBlock`, while the block above, derived since 2026-09-06 from the archive node, carries
|
||||
them. They are inert on a follower, because they act only when a node PRODUCES seals (the per-block
|
||||
Falcon rule armed by `aere.falcon.forkBlock` is retired by the shipped code at the anchor block), and so
|
||||
is `aere.pq.anchor.maxSeals`, a cap on what a PROPOSER writes: the block carries the archive node's own
|
||||
value, nine, while the validators run ten, and a follower never proposes. A VALIDATOR additionally passes
|
||||
its two key files (`aere.falcon.key` and `aere.pq.hybrid.key.slh-dsa-sha2-128s`, both generated on the
|
||||
operator's own machine, see "Becoming a validator" below), the four emission heights
|
||||
(`aere.pq.hybrid.attachBlock` and the proposal, prepare and round-change attach heights), the validators'
|
||||
seal cap, and does not set `aere.pq.anchor.minSealsCeiling`. 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.
|
||||
@ -550,6 +555,24 @@ and the option is not part of the follower configuration above [NOT MEASURED].
|
||||
|
||||
---
|
||||
|
||||
## Becoming a validator: the operator kit (added 2026-10-20)
|
||||
|
||||
The `validator/` directory of this repository is what an independent operator uses to become a
|
||||
validator of chain 2800, on the operator's own machine and without anything from the Foundation but
|
||||
public parameters. It needs the build that `./run-node.sh --no-start` makes (the key tools are classes of
|
||||
that build, patch 0008) and a JDK 21:
|
||||
|
||||
| file | what it does |
|
||||
|---|---|
|
||||
| `validator/validator-keys.sh` | generates the Falcon-512 and SLH-DSA-SHA2-128s keys with `PqRegistryV3Tool keygen` from your build, owner-only, never overwriting; uses the node key `data/key` (it is also the consensus key); prints only public keys and the post-quantum id |
|
||||
| `validator/validator-candidacy.sh` | checks that your running node uses that node key, measures your clock against the chain head, writes your registry row with `PqRegistryV3Tool row`, signs a candidacy message with all three keys, and writes `aere-candidacy.json` (public data only); `--clock-only` writes the fresh signed clock report asked for on the day of the vote |
|
||||
| `validator/AereCandidacy.java` | the signing helper both scripts call, run by the JDK's source launcher against your build's `lib/`; the two message formats are written in its header and in `validator/README.md` |
|
||||
| `validator/README.md` | the operator runbook: keys, clock, admission step by step, the validator properties; the same text is served at `https://aere.network/validator/README.md` |
|
||||
|
||||
Both scripts refuse a build made from another patch set, a key file readable by anyone but its owner,
|
||||
and a node that runs with another node key than the one registered; and both check their own output for
|
||||
any private value before they end. No key, private or public, of any validator is in this repository.
|
||||
|
||||
## Start it
|
||||
|
||||
Export `BESU_OPTS` as above first, then:
|
||||
|
||||
10
SPEC.md
10
SPEC.md
@ -684,7 +684,9 @@ Semantics of each parameter (source: `PqAnchorConfig.java`):
|
||||
than K (measured seal counts at K=3, N=7: 42 blocks with 4 seals, 36 with 5, 5 with 6; a
|
||||
Falcon-512 seal is 666 bytes) (source: `PqAnchorConfig.java`, `PROPERTY_MAX_SEALS` javadoc,
|
||||
measurement dated 2026-08-07). Real anchor blocks carry three to nine seals (source:
|
||||
`RUN-A-NODE.md`).
|
||||
`RUN-A-NODE.md`). *Note added 2026-10-20:* the cap in the block above is the archive node's own value
|
||||
(nine); the validators run ten (the validator kit, `validator/README.md`). The cap only limits what a
|
||||
PROPOSER writes, so on a follower it is inert.
|
||||
- `aere.pq.anchor.disable`: local emergency de-arm; both rules off on this node; per-block disarm
|
||||
announcement keeps shouting. Local on purpose: a halted chain cannot deliver an on-chain change.
|
||||
- `aere.pq.chainId`: enters the D and M pre-images (domain separation from the proving chain
|
||||
@ -692,8 +694,10 @@ Semantics of each parameter (source: `PqAnchorConfig.java`):
|
||||
- `aere.falcon.manifest` + `aere.falcon.anchor.address`/`anchor.block`: head registry wiring;
|
||||
`aere.falcon.registry.history`: every earlier registry, never pruned (a from-genesis node
|
||||
verifies every block ever produced) (source: `registries/README.md`, `RUN-A-NODE.md`).
|
||||
- `aere.falcon.forkBlock`: a validator-side legacy per-block rule; see section 8, D6. A follower
|
||||
leaves it unset.
|
||||
- `aere.falcon.forkBlock`: a validator-side legacy per-block rule; see section 8, D6. *Corrected
|
||||
2026-10-20:* this line said a follower leaves it unset; the block above, derived from the archive node,
|
||||
carries it, and it is inert there because the shipped code retires the per-block rule at the anchor
|
||||
block.
|
||||
- `aere.pq.checkpoint` (added to this list 2026-10-07; in the recipe since patch 0008 of 2026-10-01,
|
||||
AIP-22 stage 4 code): `<height>:<block hash>[,<height>:<block hash>...]`, read by `PqCheckpointRule`,
|
||||
an attached header rule of the QBFT rule set (`QbftBlockHeaderValidationRulesetFactory`, patch 0008),
|
||||
|
||||
22
run-node.sh
22
run-node.sh
@ -226,14 +226,22 @@ fi
|
||||
|
||||
mkdir -p "$DATA_PATH"
|
||||
|
||||
# BESU_OPTS: the follower subset of chain 2800's post-quantum configuration.
|
||||
# BESU_OPTS: chain 2800's post-quantum configuration for a FOLLOWER.
|
||||
#
|
||||
# WHY THESE AND NOT THE VALIDATOR SET. A validator additionally sets, and a follower deliberately
|
||||
# OMITS: aere.falcon.key (the signing key, not in this repo), aere.falcon.attachBlock /
|
||||
# attachInterval / minAttachFutureMargin / validatorCount (these drive seal PRODUCTION only), and
|
||||
# aere.falcon.forkBlock (a per-block Falcon rule that the shipped code retires at the anchor block,
|
||||
# so it is inert; leaving it unset keeps the non-blocking baseline). What is below is exactly what
|
||||
# the IMPORT (validation) path reads, verified against anchor/ source:
|
||||
# WHAT THE BLOCK BELOW IS (corrected 2026-10-20: until then this comment said a follower omits the
|
||||
# attach, validatorCount and forkBlock properties, while the block, derived since 2026-09-06 from the
|
||||
# live archive node, carries them). The block is the archive node's own configuration, minus keys,
|
||||
# regenerated by tooling and never written by hand. A few of its properties act only when a node
|
||||
# PRODUCES seals, and are inert on a follower: aere.falcon.attachBlock, attachInterval,
|
||||
# minAttachFutureMargin and validatorCount, aere.falcon.forkBlock (a per-block Falcon rule the shipped
|
||||
# code retires at the anchor block), and aere.pq.anchor.maxSeals, a cap on what a PROPOSER writes:
|
||||
# the value below is the archive node's own (9), the validators run 10, and a follower never proposes.
|
||||
# A VALIDATOR additionally passes its two key files (aere.falcon.key and
|
||||
# aere.pq.hybrid.key.slh-dsa-sha2-128s, generated on the operator's machine by
|
||||
# validator/validator-keys.sh), the four emission heights (aere.pq.hybrid.attachBlock and the
|
||||
# proposal, prepare and round-change attach heights), the validators' seal cap, and does NOT set the
|
||||
# seal-threshold ceiling below: validator/README.md, section 7b. What the IMPORT (validation) path
|
||||
# reads, verified against anchor/ source:
|
||||
# - registries: aere.falcon.manifest is the head registry; it loads only WITH
|
||||
# aere.falcon.anchor.address (the manifest source is pending until that on-chain anchor is
|
||||
# observed), so anchor.address / anchor.block are import-relevant here, not attach knobs.
|
||||
|
||||
498
validator/AereCandidacy.java
Normal file
498
validator/AereCandidacy.java
Normal file
@ -0,0 +1,498 @@
|
||||
/*
|
||||
* AereCandidacy.java - the signing helper of the Aere Network validator kit (chain 2800).
|
||||
*
|
||||
* It runs on the CANDIDATE's machine, against the lib/ directory of the candidate's OWN build of the
|
||||
* Aere fork (run-node.sh --no-start builds it from the published patch series), with the JDK's
|
||||
* single-file source launcher:
|
||||
*
|
||||
* java -cp '<build>/build/install/besu/lib/*' AereCandidacy.java <command> [options]
|
||||
*
|
||||
* It uses the classes of that build and nothing else: the registry v3 forms (PqRegistryV3, patch 0008),
|
||||
* the Falcon-512 and SLH-DSA-SHA2-128s schemes the node signs with, Besu's secp256k1 and Besu's own
|
||||
* KeyPairUtil (the code path that creates data/key when a node starts for the first time).
|
||||
*
|
||||
* WHAT IT NEVER DOES: print, log, copy or transmit any private key, or any part of one. It reads the
|
||||
* key files, signs, verifies its own signatures before writing anything, and writes PUBLIC data only.
|
||||
* The shell scripts next to it check that again on their own output (validator-candidacy.sh).
|
||||
*
|
||||
* Commands:
|
||||
* params --params <admission-params.json> prints "chainId bindHeight count index"
|
||||
* node-key --data-path <dir> creates <dir>/key with Besu's KeyPairUtil if
|
||||
* (and only if) it does not exist
|
||||
* ids --node-key-file <key> --falcon-key <f> --slh-key <s> prints the public identity (JSON)
|
||||
* candidacy --params <p> --row <row.json> --falcon-key <f> --slh-key <s> --node-key-file <key>
|
||||
* --enode-host <host> --p2p-port <n> --issued <YYYY-MM-DD> --clock-measured-at <unix>
|
||||
* --clock-head <n> --clock-samples <n> --clock-dmin <s> --clock-dmax <s> --out <file>
|
||||
* clock-report --chain-id <c> --node-key-file <key> --enode-host <host> --p2p-port <n>
|
||||
* --clock-measured-at <unix> --clock-head <n> --clock-samples <n> --clock-dmin <s>
|
||||
* --clock-dmax <s> --out <file>
|
||||
*
|
||||
* The two signed messages (also written out in validator/README.md, "The candidacy file", so that
|
||||
* anyone can verify a candidacy without this file):
|
||||
*
|
||||
* candidacy = keccak256("AERE-CANDIDACY-1" || u64 chainId || u64 bindHeight || u32 count || u32 index
|
||||
* || u32 len(issued) || issued (ASCII, YYYY-MM-DD) || id (20 bytes) || rowHash (32 bytes))
|
||||
* clock = keccak256("AERE-CLOCK-1" || u64 chainId || u64 measuredAt || u64 headNumber || u32 samples
|
||||
* || i32 dMin || i32 dMax)
|
||||
*
|
||||
* signed by: candidacy -> the node key (secp256k1, 65 bytes r||s||v), the Falcon-512 key and the
|
||||
* SLH-DSA-SHA2-128s key of the row; clock -> the node key. Integers are big-endian; i32 is two's
|
||||
* complement. rowHash is the registry v3 row hash (AIP-22 section 3.2, PqRegistryV3.rowHash). The node
|
||||
* key is bound through its ADDRESS: the secp256k1 signatures must recover to the row's ecdsa address,
|
||||
* which is also its transport address, and the enode's public key must hash to that same address.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSealScheme;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqRegistryV3;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealScheme;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||
import org.hyperledger.besu.crypto.Hash;
|
||||
import org.hyperledger.besu.crypto.KeyPair;
|
||||
import org.hyperledger.besu.crypto.KeyPairUtil;
|
||||
import org.hyperledger.besu.crypto.SECPPublicKey;
|
||||
import org.hyperledger.besu.crypto.SECPSignature;
|
||||
import org.hyperledger.besu.crypto.SignatureAlgorithm;
|
||||
import org.hyperledger.besu.crypto.SignatureAlgorithmFactory;
|
||||
import org.hyperledger.besu.ethereum.core.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
|
||||
public class AereCandidacy {
|
||||
|
||||
static final String CANDIDACY_DOMAIN = "AERE-CANDIDACY-1";
|
||||
static final String CLOCK_DOMAIN = "AERE-CLOCK-1";
|
||||
static final String CANDIDACY_FORMAT = "aere-validator-candidacy/1";
|
||||
static final String CLOCK_FORMAT = "aere-validator-clock/1";
|
||||
static final String PARAMS_FORMAT = "aere-admission-params/1";
|
||||
static final ObjectMapper M = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
static final SignatureAlgorithm EC = SignatureAlgorithmFactory.getInstance();
|
||||
static final Pattern HOST = Pattern.compile("^([A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\\[[0-9A-Fa-f:.]{2,45}\\])$");
|
||||
static final Pattern DATE = Pattern.compile("^20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$");
|
||||
|
||||
public static void main(final String[] args) throws IOException {
|
||||
if (args.length == 0) {
|
||||
die(2, "usage: AereCandidacy.java params|node-key|ids|candidacy|clock-report [options] (see the header)");
|
||||
}
|
||||
final Map<String, String> o = new HashMap<>();
|
||||
for (int i = 1; i < args.length; i++) {
|
||||
if (!args[i].startsWith("--") || i + 1 >= args.length) {
|
||||
die(2, "options are --name value pairs; bad argument: " + args[i]);
|
||||
}
|
||||
o.put(args[i].substring(2), args[++i]);
|
||||
}
|
||||
try {
|
||||
switch (args[0]) {
|
||||
case "params" -> {
|
||||
final long[] p = params(o);
|
||||
System.out.println(p[0] + " " + p[1] + " " + p[2] + " " + p[3]);
|
||||
}
|
||||
case "node-key" -> nodeKey(o);
|
||||
case "ids" -> ids(o);
|
||||
case "candidacy" -> candidacy(o);
|
||||
case "clock-report" -> clockReport(o);
|
||||
default -> die(2, "unknown command " + args[0]);
|
||||
}
|
||||
} catch (final IllegalArgumentException | IllegalStateException e) {
|
||||
// messages here never carry key material: every one of them is written below by hand
|
||||
die(1, "REFUSED: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the admission parameters the Foundation sends (public) ----------------------------------
|
||||
static long[] params(final Map<String, String> o) throws IOException {
|
||||
final JsonNode p = M.readTree(Files.readString(Path.of(need(o, "params")), StandardCharsets.UTF_8));
|
||||
if (p == null || !p.isObject()) {
|
||||
throw new IllegalArgumentException("the admission parameters are not a JSON object");
|
||||
}
|
||||
final Set<String> allowed = Set.of("format", "chainId", "bindHeight", "count", "index", "note");
|
||||
for (final Iterator<String> it = p.fieldNames(); it.hasNext(); ) {
|
||||
final String f = it.next();
|
||||
if (!allowed.contains(f)) {
|
||||
throw new IllegalArgumentException("the admission parameters carry an unknown field: " + f);
|
||||
}
|
||||
}
|
||||
if (!PARAMS_FORMAT.equals(p.path("format").asText())) {
|
||||
throw new IllegalArgumentException("the admission parameters are not of format " + PARAMS_FORMAT);
|
||||
}
|
||||
final long chain = integral(p, "chainId");
|
||||
final long bind = integral(p, "bindHeight");
|
||||
final long count = integral(p, "count");
|
||||
final long index = integral(p, "index");
|
||||
if (chain <= 0 || bind <= 0 || count < 1 || count > 64 || index < 0 || index >= count) {
|
||||
throw new IllegalArgumentException("the admission parameters are out of range (chainId, bindHeight > 0; 1 <= count <= 64; 0 <= index < count)");
|
||||
}
|
||||
return new long[] {chain, bind, count, index};
|
||||
}
|
||||
|
||||
// ---- the node key: Besu's own path, never a second implementation -----------------------------
|
||||
static void nodeKey(final Map<String, String> o) {
|
||||
final Path dir = Path.of(need(o, "data-path"));
|
||||
final Path key = dir.resolve("key");
|
||||
if (Files.exists(key)) {
|
||||
throw new IllegalStateException(key + " already exists; nothing was created (the node key is never replaced)");
|
||||
}
|
||||
if (!Files.isDirectory(dir)) {
|
||||
throw new IllegalStateException(dir + " is not a directory");
|
||||
}
|
||||
final KeyPair kp = KeyPairUtil.loadKeyPair(dir);
|
||||
final ObjectNode out = M.createObjectNode();
|
||||
out.put("created", key.toString());
|
||||
out.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe()));
|
||||
out.put("nodePublicKey", kp.getPublicKey().getEncodedBytes().toHexString());
|
||||
print(out);
|
||||
}
|
||||
|
||||
// ---- the public identity ------------------------------------------------------------------------
|
||||
static void ids(final Map<String, String> o) throws IOException {
|
||||
final KeyPair kp = nodeKeyPair(need(o, "node-key-file"));
|
||||
final Falcon f = falcon(need(o, "falcon-key"), -1);
|
||||
final Slh s = slh(need(o, "slh-key"), -1);
|
||||
if (f.index != s.index) {
|
||||
throw new IllegalArgumentException("the Falcon key file is for index " + f.index + ", the SLH-DSA key file for index " + s.index);
|
||||
}
|
||||
final ObjectNode out = M.createObjectNode();
|
||||
out.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe()));
|
||||
out.put("nodePublicKey", kp.getPublicKey().getEncodedBytes().toHexString());
|
||||
out.put("index", f.index);
|
||||
out.put("id", hx(PqRegistryV3.idOf(f.std)));
|
||||
out.put("falcon512PublicKeySha256", Hash.sha256(Bytes.wrap(f.std)).toHexString());
|
||||
out.put("slhDsaSha2128sPublicKey", hx(s.pk));
|
||||
print(out);
|
||||
}
|
||||
|
||||
// ---- the candidacy --------------------------------------------------------------------------------
|
||||
static void candidacy(final Map<String, String> o) throws IOException {
|
||||
final long[] p = params(o);
|
||||
final long chain = p[0], bind = p[1];
|
||||
final int count = (int) p[2], index = (int) p[3];
|
||||
final String issued = need(o, "issued");
|
||||
if (!DATE.matcher(issued).matches()) {
|
||||
throw new IllegalArgumentException("--issued must be a UTC date YYYY-MM-DD");
|
||||
}
|
||||
final KeyPair kp = nodeKeyPair(need(o, "node-key-file"));
|
||||
final byte[] addr = Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe();
|
||||
final byte[] nodePub = kp.getPublicKey().getEncodedBytes().toArrayUnsafe();
|
||||
final Falcon f = falcon(need(o, "falcon-key"), index);
|
||||
final Slh s = slh(need(o, "slh-key"), index);
|
||||
final String enode = enode(nodePub, o);
|
||||
final Path out = Path.of(need(o, "out"));
|
||||
if (Files.exists(out)) {
|
||||
throw new IllegalStateException(out + " exists; a candidacy file is never overwritten");
|
||||
}
|
||||
|
||||
// the row, as PqRegistryV3Tool wrote it: it must be THIS node's keys, bound to THESE parameters
|
||||
final JsonNode rf = M.readTree(Files.readString(Path.of(need(o, "row")), StandardCharsets.UTF_8));
|
||||
if (rf == null || rf.path("index").asInt(-1) != index || !rf.has("row")) {
|
||||
throw new IllegalArgumentException("the row file is not a PqRegistryV3Tool row for index " + index);
|
||||
}
|
||||
final JsonNode row = rf.get("row");
|
||||
final byte[] id = hex(text(row, "id"));
|
||||
if (!Arrays.equals(id, PqRegistryV3.idOf(f.std))) {
|
||||
throw new IllegalArgumentException("the row's id is not the derivation of this Falcon key (a new id must be)");
|
||||
}
|
||||
if (!Arrays.equals(hex(text(row.path("keys"), "falcon-512")), f.std)
|
||||
|| !Arrays.equals(hex(text(row.path("keys"), "slh-dsa-sha2-128s")), s.pk)) {
|
||||
throw new IllegalArgumentException("the row carries other public keys than the key files given");
|
||||
}
|
||||
if (row.has("rotation")) {
|
||||
throw new IllegalArgumentException("the row carries a rotation; a new validator's row never does");
|
||||
}
|
||||
final byte[] transport = hex(text(row.path("transport"), "secp256k1Address"));
|
||||
final byte[] ecAddr = hex(text(row.path("ecdsa"), "addr"));
|
||||
if (!Arrays.equals(transport, addr) || !Arrays.equals(ecAddr, addr)) {
|
||||
throw new IllegalArgumentException("the row's transport and ecdsa addresses must both be the address of the NODE key"
|
||||
+ " (the consensus key is the node key; a validator whose node key differs receives no consensus message)");
|
||||
}
|
||||
final byte[] falconPop = hex(text(row.path("pop"), "falcon-512"));
|
||||
final byte[] slhPop = hex(text(row.path("pop"), "slh-dsa-sha2-128s"));
|
||||
final TreeMap<Integer, PqRegistryV3.Key> keys = new TreeMap<>();
|
||||
keys.put(SealSchemes.FALCON_512.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.FALCON_512, f.std, falconPop));
|
||||
keys.put(SealSchemes.SLH_DSA_128S.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.SLH_DSA_128S, s.pk, slhPop));
|
||||
final Bytes32 rowHash =
|
||||
PqRegistryV3.rowHash(chain, bind, count, new PqRegistryV3.Row(index, id, keys, transport, Map.of(), null, null));
|
||||
// the row's own proofs, checked again here before this node signs anything about it
|
||||
if (!SealSchemes.FALCON_512.verifyRaw(f.raw896,
|
||||
PqRegistryV3.popMessage(chain, bind, count, index, id, SealSchemes.FALCON_512.wireId(), f.std).toArrayUnsafe(), falconPop)
|
||||
|| !SealSchemes.SLH_DSA_128S.verifyRaw(s.pk,
|
||||
PqRegistryV3.popMessage(chain, bind, count, index, id, SealSchemes.SLH_DSA_128S.wireId(), s.pk).toArrayUnsafe(), slhPop)) {
|
||||
throw new IllegalStateException("a proof of possession in the row does not verify for these parameters");
|
||||
}
|
||||
final SECPSignature claim = EC.decodeSignature(Bytes.wrap(hex(text(row.path("ecdsa"), "claim"))));
|
||||
final Optional<SECPPublicKey> claimer = EC.recoverPublicKeyFromSignature(PqRegistryV3.claimDigest(chain, rowHash), claim);
|
||||
if (claimer.isEmpty() || !Arrays.equals(Util.publicKeyToAddress(claimer.get()).getBytes().toArrayUnsafe(), addr)) {
|
||||
throw new IllegalStateException("the row's ecdsa claim does not recover to the node key's address");
|
||||
}
|
||||
|
||||
// the candidacy proof: three signatures over a message bound to the chain, the epoch, the date and the row
|
||||
final Bytes32 msg = candidacyMessage(chain, bind, count, index, issued, id, rowHash);
|
||||
final SECPSignature es = EC.sign(msg, kp);
|
||||
final byte[] fs = ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(f.sk, msg.toArrayUnsafe())
|
||||
.orElseThrow(() -> new IllegalStateException("the Falcon key did not sign"));
|
||||
final byte[] ss = SealSchemes.SLH_DSA_128S.sign(s.sk, msg.toArrayUnsafe())
|
||||
.orElseThrow(() -> new IllegalStateException("the SLH-DSA key did not sign"));
|
||||
selfCheck(msg, es, nodePub, f, fs, s, ss);
|
||||
final ObjectNode clock = clock(o, chain, nodePub, kp);
|
||||
|
||||
final ObjectNode c = M.createObjectNode();
|
||||
c.put("format", CANDIDACY_FORMAT);
|
||||
c.put("chainId", chain);
|
||||
c.put("bindHeight", bind);
|
||||
c.put("count", count);
|
||||
c.put("index", index);
|
||||
c.put("issued", issued);
|
||||
c.put("enode", enode);
|
||||
c.put("address", hx(addr));
|
||||
c.put("id", hx(id));
|
||||
c.set("row", row);
|
||||
c.put("rowHash", rowHash.toHexString());
|
||||
final ObjectNode pr = c.putObject("proof");
|
||||
pr.put("domain", CANDIDACY_DOMAIN);
|
||||
pr.put("message", msg.toHexString());
|
||||
pr.put("secp256k1", es.encodedBytes().toHexString());
|
||||
pr.put("falcon-512", hx(fs));
|
||||
pr.put("slh-dsa-sha2-128s", hx(ss));
|
||||
c.set("clock", clock);
|
||||
Files.writeString(out, M.writeValueAsString(c) + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW);
|
||||
final ObjectNode sum = M.createObjectNode();
|
||||
sum.put("written", out.toString());
|
||||
sum.put("enode", enode);
|
||||
sum.put("address", hx(addr));
|
||||
sum.put("id", hx(id));
|
||||
sum.put("rowHash", rowHash.toHexString());
|
||||
print(sum);
|
||||
}
|
||||
|
||||
static void clockReport(final Map<String, String> o) throws IOException {
|
||||
final long chain = Long.parseLong(need(o, "chain-id"));
|
||||
final KeyPair kp = nodeKeyPair(need(o, "node-key-file"));
|
||||
final byte[] nodePub = kp.getPublicKey().getEncodedBytes().toArrayUnsafe();
|
||||
final Path out = Path.of(need(o, "out"));
|
||||
if (Files.exists(out)) {
|
||||
throw new IllegalStateException(out + " exists; a clock report is never overwritten");
|
||||
}
|
||||
final ObjectNode c = M.createObjectNode();
|
||||
c.put("format", CLOCK_FORMAT);
|
||||
c.put("chainId", chain);
|
||||
c.put("enode", enode(nodePub, o));
|
||||
c.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe()));
|
||||
c.set("clock", clock(o, chain, nodePub, kp));
|
||||
Files.writeString(out, M.writeValueAsString(c) + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW);
|
||||
final ObjectNode sum = M.createObjectNode();
|
||||
sum.put("written", out.toString());
|
||||
sum.set("clock", c.get("clock"));
|
||||
print(sum);
|
||||
}
|
||||
|
||||
// ---- messages -------------------------------------------------------------------------------------
|
||||
static Bytes32 candidacyMessage(final long chain, final long bind, final int count, final int index, final String issued,
|
||||
final byte[] id, final Bytes32 rowHash) {
|
||||
final byte[] d = CANDIDACY_DOMAIN.getBytes(StandardCharsets.US_ASCII);
|
||||
final byte[] is = issued.getBytes(StandardCharsets.US_ASCII);
|
||||
final ByteBuffer b = ByteBuffer.allocate(d.length + 8 + 8 + 4 + 4 + 4 + is.length + 20 + 32);
|
||||
b.put(d).putLong(chain).putLong(bind).putInt(count).putInt(index).putInt(is.length).put(is).put(id)
|
||||
.put(rowHash.toArrayUnsafe());
|
||||
return Hash.keccak256(Bytes.wrap(b.array()));
|
||||
}
|
||||
|
||||
static Bytes32 clockMessage(final long chain, final long measuredAt, final long head, final int samples, final int dMin,
|
||||
final int dMax) {
|
||||
final byte[] d = CLOCK_DOMAIN.getBytes(StandardCharsets.US_ASCII);
|
||||
final ByteBuffer b = ByteBuffer.allocate(d.length + 8 + 8 + 8 + 4 + 4 + 4);
|
||||
b.put(d).putLong(chain).putLong(measuredAt).putLong(head).putInt(samples).putInt(dMin).putInt(dMax);
|
||||
return Hash.keccak256(Bytes.wrap(b.array()));
|
||||
}
|
||||
|
||||
static ObjectNode clock(final Map<String, String> o, final long chain, final byte[] nodePub, final KeyPair kp) {
|
||||
final long at = Long.parseLong(need(o, "clock-measured-at"));
|
||||
final long head = Long.parseLong(need(o, "clock-head"));
|
||||
final int samples = Integer.parseInt(need(o, "clock-samples"));
|
||||
final int dMin = Integer.parseInt(need(o, "clock-dmin"));
|
||||
final int dMax = Integer.parseInt(need(o, "clock-dmax"));
|
||||
if (samples < 1 || dMin > dMax || head < 1 || at < 1) {
|
||||
throw new IllegalArgumentException("the clock measurement is malformed");
|
||||
}
|
||||
final Bytes32 cm = clockMessage(chain, at, head, samples, dMin, dMax);
|
||||
final SECPSignature cs = EC.sign(cm, kp);
|
||||
final Optional<SECPPublicKey> back = EC.recoverPublicKeyFromSignature(cm, cs);
|
||||
if (back.isEmpty() || !Arrays.equals(back.get().getEncodedBytes().toArrayUnsafe(), nodePub)) {
|
||||
throw new IllegalStateException("the clock signature does not recover to the node key; nothing written");
|
||||
}
|
||||
final ObjectNode c = M.createObjectNode();
|
||||
c.put("domain", CLOCK_DOMAIN);
|
||||
c.put("measuredAt", at);
|
||||
c.put("headNumber", head);
|
||||
c.put("samples", samples);
|
||||
c.put("dMin", dMin);
|
||||
c.put("dMax", dMax);
|
||||
c.put("secp256k1", cs.encodedBytes().toHexString());
|
||||
return c;
|
||||
}
|
||||
|
||||
static void selfCheck(final Bytes32 msg, final SECPSignature es, final byte[] nodePub, final Falcon f, final byte[] fs,
|
||||
final Slh s, final byte[] ss) {
|
||||
final Optional<SECPPublicKey> back = EC.recoverPublicKeyFromSignature(msg, es);
|
||||
if (back.isEmpty() || !Arrays.equals(back.get().getEncodedBytes().toArrayUnsafe(), nodePub)) {
|
||||
throw new IllegalStateException("the secp256k1 signature does not recover to the node key; nothing written");
|
||||
}
|
||||
if (!SealSchemes.FALCON_512.verifyRaw(f.raw896, msg.toArrayUnsafe(), fs)) {
|
||||
throw new IllegalStateException("the Falcon-512 signature does not verify; nothing written");
|
||||
}
|
||||
if (!SealSchemes.SLH_DSA_128S.verifyRaw(s.pk, msg.toArrayUnsafe(), ss)) {
|
||||
throw new IllegalStateException("the SLH-DSA signature does not verify; nothing written");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- key files (read, never printed) -----------------------------------------------------------
|
||||
record Falcon(int index, FalconPrivateKeyParameters sk, byte[] raw896, byte[] std) {}
|
||||
|
||||
record Slh(int index, SealScheme.PrivateHandle sk, byte[] pk) {}
|
||||
|
||||
static Falcon falcon(final String file, final int wantIndex) throws IOException {
|
||||
final Properties fp = new Properties();
|
||||
fp.load(new StringReader(Files.readString(Path.of(file), StandardCharsets.UTF_8)));
|
||||
final int index = Integer.parseInt(prop(fp, "index", "the Falcon key file"));
|
||||
if (wantIndex >= 0 && index != wantIndex) {
|
||||
throw new IllegalArgumentException("the Falcon key file is for index " + index + ", the admission parameters say " + wantIndex);
|
||||
}
|
||||
final byte[] h = hex(prop(fp, "pk", "the Falcon key file"));
|
||||
if (h.length != PqRegistryV3.FALCON_512_PK_LENGTH - 1) {
|
||||
throw new IllegalArgumentException("the Falcon key file's public key is not 896 bytes");
|
||||
}
|
||||
final FalconPrivateKeyParameters sk = new FalconPrivateKeyParameters(FalconParameters.falcon_512,
|
||||
hex(prop(fp, "f", "the Falcon key file")), hex(prop(fp, "g", "the Falcon key file")), hex(prop(fp, "F", "the Falcon key file")), h);
|
||||
final byte[] std = new byte[PqRegistryV3.FALCON_512_PK_LENGTH];
|
||||
std[0] = PqRegistryV3.FALCON_512_PK_HEADER;
|
||||
System.arraycopy(h, 0, std, 1, h.length);
|
||||
// the private half must match the public half: sign a probe and verify it under the public key
|
||||
final byte[] probe = Hash.keccak256(Bytes.wrap("AERE-KIT-PROBE".getBytes(StandardCharsets.US_ASCII))).toArrayUnsafe();
|
||||
final byte[] sig = ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(sk, probe)
|
||||
.orElseThrow(() -> new IllegalArgumentException("the Falcon key does not sign"));
|
||||
if (!SealSchemes.FALCON_512.verifyRaw(h, probe, sig)) {
|
||||
throw new IllegalArgumentException("the Falcon key file's private and public halves do not match");
|
||||
}
|
||||
return new Falcon(index, sk, h, std);
|
||||
}
|
||||
|
||||
static Slh slh(final String file, final int wantIndex) throws IOException {
|
||||
final Properties sp = new Properties();
|
||||
sp.load(new StringReader(Files.readString(Path.of(file), StandardCharsets.UTF_8)));
|
||||
final int index = Integer.parseInt(prop(sp, "index", "the SLH-DSA key file"));
|
||||
if (wantIndex >= 0 && index != wantIndex) {
|
||||
throw new IllegalArgumentException("the SLH-DSA key file is for index " + index + ", the admission parameters say " + wantIndex);
|
||||
}
|
||||
if (!"slh-dsa-sha2-128s".equals(sp.getProperty("alg", "").trim())) {
|
||||
throw new IllegalArgumentException("the SLH-DSA key file does not name alg=slh-dsa-sha2-128s");
|
||||
}
|
||||
final SealScheme.PrivateHandle sk = SealSchemes.SLH_DSA_128S.parsePrivateKey(hex(prop(sp, "sk", "the SLH-DSA key file")))
|
||||
.orElseThrow(() -> new IllegalArgumentException("the SLH-DSA private key does not parse"));
|
||||
final byte[] pk = hex(prop(sp, "pk", "the SLH-DSA key file"));
|
||||
final byte[] probe = Hash.keccak256(Bytes.wrap("AERE-KIT-PROBE".getBytes(StandardCharsets.US_ASCII))).toArrayUnsafe();
|
||||
final byte[] sig = SealSchemes.SLH_DSA_128S.sign(sk, probe).orElseThrow(() -> new IllegalArgumentException("the SLH-DSA key does not sign"));
|
||||
if (!SealSchemes.SLH_DSA_128S.verifyRaw(pk, probe, sig)) {
|
||||
throw new IllegalArgumentException("the SLH-DSA key file's private and public halves do not match");
|
||||
}
|
||||
return new Slh(index, sk, pk);
|
||||
}
|
||||
|
||||
static KeyPair nodeKeyPair(final String file) throws IOException {
|
||||
final String t = Files.readString(Path.of(file), StandardCharsets.UTF_8).trim();
|
||||
if (!t.matches("^(0x)?[0-9a-fA-F]{64}$")) {
|
||||
throw new IllegalArgumentException("the node key file does not hold a 32-byte hex key");
|
||||
}
|
||||
return EC.createKeyPair(EC.createPrivateKey(Bytes32.fromHexString(t)));
|
||||
}
|
||||
|
||||
static String enode(final byte[] nodePub, final Map<String, String> o) {
|
||||
final String host = need(o, "enode-host");
|
||||
final int port = Integer.parseInt(need(o, "p2p-port"));
|
||||
if (!HOST.matcher(host).matches() || port < 1 || port > 65535) {
|
||||
throw new IllegalArgumentException("--enode-host must be a host name or an IP address and --p2p-port a port number");
|
||||
}
|
||||
return "enode://" + hx(nodePub).substring(2) + "@" + host + ":" + port;
|
||||
}
|
||||
|
||||
// ---- small helpers -----------------------------------------------------------------------------
|
||||
static String prop(final Properties p, final String k, final String what) {
|
||||
final String v = p.getProperty(k);
|
||||
if (v == null || v.isBlank()) {
|
||||
throw new IllegalArgumentException(what + " has no '" + k + "' entry");
|
||||
}
|
||||
return v.trim();
|
||||
}
|
||||
|
||||
static long integral(final JsonNode p, final String k) {
|
||||
final JsonNode v = p.get(k);
|
||||
if (v == null || !v.isIntegralNumber()) {
|
||||
throw new IllegalArgumentException("the admission parameters need the integer field " + k);
|
||||
}
|
||||
return v.asLong();
|
||||
}
|
||||
|
||||
static String text(final JsonNode n, final String k) {
|
||||
final JsonNode v = n == null ? null : n.get(k);
|
||||
if (v == null || !v.isTextual()) {
|
||||
throw new IllegalArgumentException("the row has no text field " + k);
|
||||
}
|
||||
return v.asText();
|
||||
}
|
||||
|
||||
static String need(final Map<String, String> o, final String k) {
|
||||
final String v = o.get(k);
|
||||
if (v == null || v.isBlank()) {
|
||||
throw new IllegalArgumentException("missing --" + k);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
static byte[] hex(final String s) {
|
||||
return Bytes.fromHexStringLenient(s.trim()).toArrayUnsafe();
|
||||
}
|
||||
|
||||
static String hx(final byte[] b) {
|
||||
final StringBuilder sb = new StringBuilder("0x");
|
||||
for (final byte x : b) {
|
||||
sb.append(String.format(Locale.ROOT, "%02x", x & 0xff));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static void print(final ObjectNode n) {
|
||||
try {
|
||||
System.out.println(M.writeValueAsString(n));
|
||||
} catch (final IOException e) {
|
||||
die(2, "cannot write output");
|
||||
}
|
||||
}
|
||||
|
||||
static void die(final int code, final String msg) {
|
||||
System.err.println(msg);
|
||||
System.exit(code);
|
||||
}
|
||||
}
|
||||
600
validator/README.md
Normal file
600
validator/README.md
Normal file
@ -0,0 +1,600 @@
|
||||
# Run an AERE node, operator runbook
|
||||
|
||||
This is the runbook for standing up an independent Hyperledger Besu node on the
|
||||
AERE Network, chain ID **2800**, and applying to become one of the first
|
||||
non-Foundation validators.
|
||||
|
||||
Read it end to end before you start. It is written for someone who has run a
|
||||
node before and is comfortable with Docker and a Linux server.
|
||||
|
||||
> **Corrected 2026-10-20.** Until this date this runbook said that a validator's post-quantum keys
|
||||
> are "issued at admission", listed a property (`aere.falcon.genesis`) that no running validator
|
||||
> carries, and did not name the SLH-DSA key at all. All three were wrong. Every key of a validator is
|
||||
> generated by its operator, on the operator's own machine, with the tools in the `validator/`
|
||||
> directory of the `aere-node` repository; nothing private ever leaves that machine. Section 6
|
||||
> describes admission as it now works, step by step, and section 3b the clock requirement.
|
||||
|
||||
---
|
||||
|
||||
## What AERE actually is today (read this first)
|
||||
|
||||
AERE runs **Hyperledger Besu** with **QBFT** consensus and **sub-second blocks** (0.62 s on average
|
||||
over the 100,000 blocks before 2026-10-15).
|
||||
Consensus is a permissioned proof-of-authority BFT set, not proof-of-work and
|
||||
not on-chain proof-of-stake.
|
||||
|
||||
- The active validator set is **10 validators as read from the chain at 2026-09-29 07:55 UTC**, all Foundation-operated; f = 3, quorum 7-of-10 (as read from the chain at 2026-09-29 07:55 UTC). Read the live set with `qbft_getValidatorsByBlockNumber` and believe the chain over this line.
|
||||
- QBFT tolerates `f = floor((n - 1) / 3)` faulty validators. At **n = 10, f = 3**:
|
||||
the network tolerates three simultaneous Byzantine faults (commit quorum 7 of 10).
|
||||
It is honest to say the set is small and single-operator today.
|
||||
- The public path is **10 to 21** validators. This program exists
|
||||
specifically to admit the **first independent (non-Foundation) operators**.
|
||||
- Admission is a **governance vote of the existing validator set**, not an
|
||||
automatic or permissionless process. There is **no staking, no bond, and no
|
||||
slashing.** A validator that misbehaves or goes down for a prolonged period is
|
||||
removed by the same governance vote that admitted it. Accountability is social
|
||||
and on-chain, not economic.
|
||||
|
||||
If you are looking for a stake-and-earn validator with slashing and unbonding,
|
||||
that is not how AERE works. Do not trust older pages that describe self-staking,
|
||||
slashing, unbonding periods, or APY tiers; they predate the current QBFT design.
|
||||
|
||||
---
|
||||
|
||||
## What running this gives you
|
||||
|
||||
A **full, syncing node** on chain 2800, built from the same published patch series the
|
||||
validators run. A validator differs from a following node in four things, all of them yours to
|
||||
hold and none of them issued by anyone else:
|
||||
|
||||
1. its node key (`data/key`), which is also its consensus key: the address voted into the set;
|
||||
2. a Falcon-512 key and an SLH-DSA-SHA2-128s key, generated on your machine (section 3);
|
||||
3. a row in a post-quantum registry epoch that carries those public keys, signed by them (section 6);
|
||||
4. the validator-only properties: the paths of the two key files and the heights from which the node
|
||||
emits post-quantum seals (section 7b).
|
||||
|
||||
So the sequence is:
|
||||
|
||||
1. Stand up a following node with this runbook (or with `run-node.sh` in `aere-node`), and keep it
|
||||
healthy. The Foundation observes it from outside for fourteen days before a vote is proposed.
|
||||
2. Generate your keys and write your candidacy file on your own machine (sections 3 and 6).
|
||||
3. The Foundation verifies the candidacy independently and puts your row into a new registry epoch,
|
||||
which every node, yours included, loads before your admission.
|
||||
4. Your node restarts as a validator and connects to every validator; then the existing validators
|
||||
vote. After the vote your node proposes and seals blocks. No reinstall, no migration.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
**Software**
|
||||
|
||||
- A Linux host (the live nodes run Docker on Linux).
|
||||
- Docker and the Docker Compose plugin, or a build from source (`run-node.sh` in `aere-node` needs git
|
||||
and a JDK 21). The key tools of section 3 need the same JDK 21 and a build from the published patch
|
||||
series, which `./run-node.sh --no-start` produces.
|
||||
|
||||
**Hardware**
|
||||
|
||||
There is no published minimum-hardware spec sheet for AERE, so treat the
|
||||
following as **guidance, not a documented requirement**. The Foundation confirms
|
||||
final sizing on application.
|
||||
|
||||
- The node is configured with a **4 GB JVM heap** (`-Xmx4g`), matching the live
|
||||
validators. Give the host meaningful headroom above that. A reasonable
|
||||
starting point is **4 vCPU, 8 GB RAM, NVMe SSD**, but confirm on application.
|
||||
- **A CPU with SHA hardware instructions** (x86 SHA-NI, or the ARMv8 SHA2 extension). A validator signs
|
||||
an SLH-DSA-SHA2-128s seal on the consensus thread at the parent of every anchor block; measured on
|
||||
chain 2800 that costs 0.3 to 0.7 s per signature with the JDK digest, which uses those instructions
|
||||
(AIP-22, table of constants). A slower signature delays the anchor round.
|
||||
- **Local NVMe, not network-attached block storage.** The node writes its post-quantum seal store on the
|
||||
consensus thread; on a network volume every such write adds milliseconds to each slot.
|
||||
- Storage uses Besu **Bonsai** format. At sub-second blocks the trie-log data
|
||||
grows faster than on a slow chain, so provision generous, growable NVMe disk
|
||||
and monitor free space over time. Block headers are most of what a node stores.
|
||||
|
||||
**Network / firewall**
|
||||
|
||||
- **30303/tcp and 30303/udp**: open to the internet. This is P2P. Peers must be
|
||||
able to reach you.
|
||||
- **8545 (JSON-RPC)**: keep private. The compose binds it to `127.0.0.1` on the
|
||||
host. Do not expose it to the public internet without an authenticating proxy
|
||||
in front.
|
||||
- **9545 (metrics)**: keep private, localhost only.
|
||||
|
||||
**RPC policy for a validator.** A validator's JSON-RPC stays on localhost, always: it serves the
|
||||
`qbft_*` methods, including `qbft_proposeValidatorVote`, and a public endpoint on a consensus node is a
|
||||
denial-of-service surface on the very node that has to answer within a slot. If you want to offer
|
||||
public reads, run a separate following node for them and keep the validator private.
|
||||
|
||||
---
|
||||
|
||||
## 2. Get the config bundle
|
||||
|
||||
Everything you need to start is in this directory
|
||||
(`https://aere.network/validator/`):
|
||||
|
||||
| File | What it is | Source |
|
||||
| --- | --- | --- |
|
||||
| `docker-compose.yml` | The node service, real QBFT flags | Published here |
|
||||
| `config.toml` | Same config for bare-metal / from-source runs | Published here |
|
||||
| `genesis.json` | Chain 2800 genesis (no private keys) | Published here |
|
||||
| `static-nodes.json` | Current peer / bootnode enode list | **Provided by the Foundation on application** |
|
||||
| Bootnode enode string | For `--bootnodes` | **Provided by the Foundation on application** |
|
||||
| Canonical genesis hash | To verify your genesis | **Provided by the Foundation on application** |
|
||||
|
||||
Download the published files into a working directory, for example
|
||||
`/opt/aere`:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/aere && cd /opt/aere
|
||||
curl -fSLO https://aere.network/validator/docker-compose.yml
|
||||
curl -fSLO https://aere.network/validator/config.toml
|
||||
curl -fSLO https://aere.network/validator/genesis.json
|
||||
```
|
||||
|
||||
### The post-quantum registry files (added 2026-09-12)
|
||||
|
||||
`docker-compose.yml` mounts two directories that the steps above did not create, and a node
|
||||
started without them cannot judge an anchor certificate at all. Fetch them:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/aere/aere-pqc /opt/aere/pq && cd /opt/aere/aere-pqc
|
||||
curl -fSLO https://aere.network/pq/manifests/manifest-13014000.json
|
||||
curl -fSLO https://aere.network/pq/manifests/manifest-18082816.json
|
||||
curl -fSLO https://aere.network/pq/manifests/registru-2800-v2-13600000.properties
|
||||
cd /opt/aere/pq
|
||||
curl -fSLO https://aere.network/pq/manifests/hibrid-10.properties
|
||||
```
|
||||
|
||||
**Check what you downloaded, because a 200 is not proof of a file.** Until 2026-09-12 two of
|
||||
these paths were not published, and the site answered every one of them with HTTP 200 and the
|
||||
51 KB HTML page of the web app. `curl -fSLO` succeeds on that: exit code 0, a file on disk, the
|
||||
wrong bytes. So look at the first character of each file, and refuse anything that starts with
|
||||
`<`:
|
||||
|
||||
```bash
|
||||
for f in /opt/aere/aere-pqc/* /opt/aere/pq/*; do
|
||||
head -c 1 "$f" | grep -q '<' && echo "NOT A REGISTRY FILE: $f" || echo "ok: $f"
|
||||
done
|
||||
```
|
||||
|
||||
The same four files ship inside the `aere-node` package, with a `SHA256SUMS` beside them, if you
|
||||
prefer to take them from there and compare.
|
||||
|
||||
**Added 2026-10-07: a fifth file, the stage-3 registry epoch.** Since block 20,836,228 (AIP-22 stage 3,
|
||||
active on chain 2800 since 2026-09-30) the header carries post-quantum validator identities, and
|
||||
`docker-compose.yml` now passes `aere.pq.identityBlock=20836228` with `aere.pq.registryV3=/opt/aere-pqc/epoca-v3-2800-20836228.json`.
|
||||
Until 2026-10-07 this file did not list that option at all: a validator started from it would have stopped at
|
||||
block 20,836,228. The epoch file is the one published as `registry-v3-20836228.json`; save it under the name
|
||||
the option expects:
|
||||
|
||||
```bash
|
||||
cd /opt/aere/aere-pqc
|
||||
curl -fSL -o epoca-v3-2800-20836228.json https://aere.network/pq/manifests/registry-v3-20836228.json
|
||||
sha256sum epoca-v3-2800-20836228.json
|
||||
```
|
||||
|
||||
Expected: `0212d8ff9e857f1c756b0004929e9ad4c7988341af4837d947e7b5263fb8c581` (194,122 bytes; measured on a
|
||||
running validator of the fleet and on the served file on 2026-10-07). The option `aere.pq.registryV3.hashes`
|
||||
pins the epoch's identity hash, so a node given the wrong file refuses to start rather than following the
|
||||
chain with a wrong registry; the first-character check above applies to this file too.
|
||||
|
||||
**Every later epoch is added, never swapped.** When an epoch that admits a new validator is announced, the
|
||||
index at `https://aere.network/pq/manifests/index.json` lists it (`registry-v3-<height>.json`, with its
|
||||
sha256 and registry hash), and every node appends the file to `aere.pq.registryV3` and its
|
||||
`<height>:<hash>` pin to `aere.pq.registryV3.hashes`, keeping the earlier ones. The announcement gives the
|
||||
exact values and the height by which every node must carry them.
|
||||
|
||||
### Verify the genesis
|
||||
|
||||
The `genesis.json` published here is the chain-2800 genesis. It contains **no
|
||||
private keys**. The `comment` fields inside `alloc` are human labels that Besu
|
||||
ignores, so they do not affect the genesis block hash.
|
||||
|
||||
After your node's first start, confirm your node computed the **same genesis
|
||||
hash** the Foundation gives you on application:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8545 \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["0x0",false],"id":1}' \
|
||||
| grep -o '"hash":"0x[0-9a-f]*"'
|
||||
```
|
||||
|
||||
The value must equal the canonical chain-2800 genesis hash provided by the
|
||||
Foundation on application. If it does not match, stop and contact the Foundation;
|
||||
do not keep running against a divergent genesis.
|
||||
|
||||
---
|
||||
|
||||
## 3. Your keys: generated on your machine, never sent anywhere
|
||||
|
||||
A validator holds three private keys. You generate all three; the Foundation never sees, holds or
|
||||
issues any of them.
|
||||
|
||||
**The node key, `data/key` (secp256k1).** Besu creates it the first time the node starts. It is your
|
||||
node's P2P identity **and your consensus key**: the address derived from it is the one voted into the
|
||||
set, and Besu sends consensus messages only to peers whose node-key address is in the set. A validator
|
||||
whose node key is not its validator key receives no consensus message at all. So never start the node
|
||||
with a node key from another place than the data directory you register.
|
||||
|
||||
Create the data directory, then start the node once so Besu writes the key:
|
||||
|
||||
```bash
|
||||
cd /opt/aere
|
||||
mkdir -p data
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Read your node's public info (enode and address) once it is up:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8545 \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","method":"net_enode","params":[],"id":1}'
|
||||
```
|
||||
|
||||
**The post-quantum keys (Falcon-512 and SLH-DSA-SHA2-128s).** They are generated by
|
||||
`PqRegistryV3Tool keygen`, a tool inside your own build of the published patch series (patch 0008),
|
||||
through the kit in the `aere-node` repository:
|
||||
|
||||
```bash
|
||||
git clone https://git.aere.network/aere-network/aere-node && cd aere-node
|
||||
./run-node.sh --no-start # builds the Aere fork from the published patches (JDK 21)
|
||||
validator/validator-keys.sh --params admission-params.json --data-path <your node's data directory>
|
||||
```
|
||||
|
||||
`admission-params.json` is a small public file the Foundation sends you (the chain id, the height and
|
||||
size of the registry epoch that will carry your row, and your row index). The script refuses unless the
|
||||
build comes from the bundle's patch series, writes the two key files owner-only (mode 600) into an
|
||||
owner-only directory (default `~/.aere-validator/keys`, outside the repository on purpose), never
|
||||
overwrites an existing key, prints only public keys and your post-quantum id, and checks its own output
|
||||
for any private value before it ends.
|
||||
|
||||
### Protect your keys
|
||||
|
||||
- `data/key` **and** the two post-quantum key files **are** your validator identity. Back them up
|
||||
together, somewhere secret and offline. Anyone with them can sign as your validator; losing them means
|
||||
a new admission.
|
||||
- **Never** `rm -rf data/`. That destroys the node key and, if you were already a
|
||||
validator, forces re-admission. To wipe chain data while keeping the key:
|
||||
|
||||
```bash
|
||||
find /opt/aere/data -mindepth 1 ! -name key -exec rm -rf {} +
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3b. The clock (added 2026-10-20)
|
||||
|
||||
Chain 2800 rejects a block header whose timestamp is more than **one second** ahead of the node's own
|
||||
clock (QBFT's `TimestampBoundedByFutureParameter(1)`), and at 0.6 s per block that rule bites at every
|
||||
head:
|
||||
|
||||
- a node whose clock is **behind** rejects the head blocks it is sent and disconnects the peer with
|
||||
`BREACH_OF_PROTOCOL`, which reads like an attack and is a clock;
|
||||
- a validator whose clock is **ahead** proposes blocks the others reject, and loses its slot.
|
||||
|
||||
What to do: run NTP or chrony against good sources, and **measure against the chain head, not against a
|
||||
flag**: a host that reports `NTPSynchronized=yes` was measured one full second behind on 2026-09-12.
|
||||
The kit measures it for you, in the same terms the Foundation measures its own fleet (head timestamp
|
||||
minus local clock, at most 1 s behind and at most 2 s ahead):
|
||||
|
||||
```bash
|
||||
validator/validator-candidacy.sh --clock-only --params admission-params.json --enode-host <your host>
|
||||
```
|
||||
|
||||
On the day of the vote the Foundation asks for exactly this report, fresh (at most six hours old). It is
|
||||
signed by your node key and holds nothing private.
|
||||
|
||||
---
|
||||
|
||||
## 4. Add peers and finish syncing
|
||||
|
||||
Until you have the Foundation's peer list, the node has nothing to connect to.
|
||||
On application you receive:
|
||||
|
||||
- a **bootnode enode** string, and
|
||||
- a **`static-nodes.json`** file (belt-and-braces; Besu reads it from inside the
|
||||
data directory).
|
||||
|
||||
Wire them in:
|
||||
|
||||
1. Put `static-nodes.json` next to the compose file. It is bind-mounted to
|
||||
`data/static-nodes.json` by the compose (uncomment that volume line if you
|
||||
commented it out).
|
||||
2. Replace `BOOTNODE_ENODE_PROVIDED_ON_APPLICATION` in `docker-compose.yml`
|
||||
(or `config.toml`) with the real enode string.
|
||||
3. Restart:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Watch it find peers and import blocks:
|
||||
|
||||
```bash
|
||||
docker compose logs -f aere-node
|
||||
```
|
||||
|
||||
### The two traps that stop a fresh node cold
|
||||
|
||||
- **The 5-peer wait.** Besu will not begin BFT block import until it has enough
|
||||
peers, and it defaults to needing **5**. A small network never reaches 5 and
|
||||
the node waits forever, never importing block 1. The compose already sets
|
||||
**`--sync-min-peers=1`** to fix this. Keep it.
|
||||
- **Gas price.** AERE nodes run **`--min-gas-price=0`**: the protocol's base-fee floor
|
||||
(from block 10,141,734) sets the price of a transaction, not the node. Do not set a
|
||||
nonzero min gas price of your own; you would drop normal AERE transactions. The
|
||||
compose already sets it to 0.
|
||||
|
||||
You do not need the public RPC fee-shim proxy. That proxy exists only for the
|
||||
public wallet-facing RPC endpoint; a syncing full node talks to the chain
|
||||
directly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Confirm the node is healthy
|
||||
|
||||
```bash
|
||||
# Current block height (should be climbing, then tracking the public head):
|
||||
curl -s -X POST http://127.0.0.1:8545 -H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
|
||||
|
||||
# Peer count (should be >= 1, ideally several):
|
||||
curl -s -X POST http://127.0.0.1:8545 -H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","method":"net_peerCount","params":[],"id":1}'
|
||||
|
||||
# The current QBFT validator set (you will NOT be in it yet):
|
||||
curl -s -X POST http://127.0.0.1:8545 -H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","method":"qbft_getValidatorsByBlockNumber","params":["latest"],"id":1}'
|
||||
```
|
||||
|
||||
Compare your height against the public head at `https://aere.network/validators`
|
||||
(the live validator-state dashboard). When your `eth_blockNumber` is tracking the
|
||||
public head and you have steady peers, your node is ready to apply.
|
||||
|
||||
---
|
||||
|
||||
## 6. Admission (the governance vote)
|
||||
|
||||
This is the honest part: **admission is a decision of the existing validators,
|
||||
not a transaction you can send yourself.** Here is the whole path, in order.
|
||||
|
||||
1. **Observation.** Your following node runs for fourteen days while the Foundation measures it from
|
||||
outside (that it follows the head, with the same block hashes as the fleet). You tell the Foundation
|
||||
the read endpoint it may use for that.
|
||||
2. **Admission parameters.** The Foundation sends `admission-params.json`: chain id 2800, the height of
|
||||
the registry epoch that will carry your row, its size, and your row index. Public data.
|
||||
3. **Your keys and your candidacy, on your machine.** `validator/validator-keys.sh` (section 3), then:
|
||||
|
||||
```bash
|
||||
validator/validator-candidacy.sh --params admission-params.json --enode-host <your public P2P host or IP> \
|
||||
--data-path <your node's data directory>
|
||||
```
|
||||
|
||||
With your node running, the script checks that the node runs with exactly the key in your data
|
||||
directory (the enode it reports must match), measures your clock against the chain head (section
|
||||
3b), writes your registry row with `PqRegistryV3Tool row` from your build (your post-quantum id,
|
||||
both public keys, a proof of possession by each, the link signed by your node key, and your node
|
||||
key's address as the transport address), signs the candidacy message with all three keys, verifies
|
||||
its own signatures, and checks that no private value appears in anything it wrote. Send the
|
||||
resulting `aere-candidacy.json`. It holds public data only.
|
||||
4. **Independent verification.** The Foundation verifies the candidacy with an implementation that
|
||||
shares no code with the client (every signature, the proofs of possession, that your id is derived
|
||||
from your Falcon key, that the enode's key is the row's address, that the date and the clock are
|
||||
fresh and in margin), and refuses it otherwise, with the reason.
|
||||
5. **The new registry epoch.** The existing validators re-sign their own rows for the new epoch (the same
|
||||
keys; a proof of possession is bound to the epoch's height and size), your row is added as the last
|
||||
one, and the epoch is checked by two independent loaders. The epoch is **published** at
|
||||
`https://aere.network/pq/manifests/` in the same hour it takes effect, and every node, yours
|
||||
included, appends its file and pin (section 2).
|
||||
6. **Your node becomes a validator.** You restart it with the validator properties of section 7b and the
|
||||
new epoch, connected to every validator (with the peer list the Foundation gives you, as in section 4),
|
||||
and you send a fresh clock report (section 3b).
|
||||
7. **The vote.** A majority of the current validators propose your post-quantum id with
|
||||
`qbft_proposeValidatorVote(<your id>, true)`. Since block 20,836,228 (AIP-22 stage 3) the set and the
|
||||
votes are on post-quantum ids, and a vote for an id that has no row in the registry in force is
|
||||
invalid: that is why the epoch comes first. The vote is proposed only after every check of the steps
|
||||
above passes, including that your node is connected to all validators.
|
||||
8. **After the vote.** Your already-running, already-synced node detects that it is now in the set and
|
||||
begins proposing and sealing blocks. The Foundation checks, from the chain and from its own nodes,
|
||||
that you propose in turn, that your post-quantum commit seal is heard and verified by every node,
|
||||
that your index appears in the anchor certificates, and that the chain's rhythm did not change.
|
||||
|
||||
The Foundation decides whether an admission grows the set or whether a Foundation validator steps out
|
||||
after you are in; for you the steps are the same.
|
||||
|
||||
**Removal** is the same mechanism in reverse:
|
||||
`qbft_proposeValidatorVote(<id>, false)` by a majority. This is how a
|
||||
misbehaving or long-offline validator is dropped. There is no slashing and no
|
||||
stake to lose; the consequence is removal from the set.
|
||||
|
||||
You can always read the live set yourself with
|
||||
`qbft_getValidatorsByBlockNumber` (see step 5), so admission and removal are
|
||||
publicly verifiable on-chain.
|
||||
|
||||
### The candidacy file
|
||||
|
||||
`aere-candidacy.json` (format `aere-validator-candidacy/1`) carries: `chainId`, `bindHeight`, `count`,
|
||||
`index`, `issued` (UTC date), `enode`, `address`, `id`, `row` (the registry v3 row, AIP-22 section 3.2),
|
||||
`rowHash`, `proof` and `clock`. The two signed messages, so that anyone can verify a candidacy without
|
||||
our tools (integers big-endian, `i32` two's complement, `||` concatenation):
|
||||
|
||||
```
|
||||
candidacy = keccak256("AERE-CANDIDACY-1" || u64 chainId || u64 bindHeight || u32 count || u32 index
|
||||
|| u32 len(issued) || issued || id (20 bytes) || rowHash (32 bytes))
|
||||
clock = keccak256("AERE-CLOCK-1" || u64 chainId || u64 measuredAt || u64 headNumber || u32 samples
|
||||
|| i32 dMin || i32 dMax)
|
||||
```
|
||||
|
||||
`proof.secp256k1` (65 bytes, r || s || v) must recover to the row's `ecdsa.addr`, which must equal its
|
||||
`transport` address and the address of the enode's public key; `proof.falcon-512` and
|
||||
`proof.slh-dsa-sha2-128s` must verify under the row's two keys; `clock.secp256k1` must recover to the
|
||||
same address. `dMin` and `dMax` are the smallest and largest "head timestamp minus local clock" over the
|
||||
samples; the margin is `dMax <= 1` and `dMin >= -2`.
|
||||
|
||||
---
|
||||
|
||||
## 7. How validators are paid
|
||||
|
||||
Rewards are transparent and on-chain. **Measured 2026-10-15:** the beneficiary of
|
||||
every block in a 40-block sample is the proposing validator's own identity address
|
||||
(ten addresses, none with code), and blocks carry almost no transactions. The split
|
||||
below is the design of the **AereCoinbaseSplitterV2** contract
|
||||
(`0x8C1A48eFA57b66fEE743A00E3899c29ad3Fd27b4`); block fees do not pass through it
|
||||
today (its balance was 0 that day), so read it as a deployed mechanism, not a
|
||||
present-tense flow. It does a fixed basis-points split:
|
||||
|
||||
- **Burn:** 3750 bps (**37.5%**) to the fee-burn vault.
|
||||
- **Sink:** 1500 bps (**15.0%**) routed through AereSink (the sAERE flywheel).
|
||||
- **Rebate to the validator:** the remainder, **47.5%** at the defaults.
|
||||
|
||||
These are the current default splits. They are owner-adjustable within hard
|
||||
caps written into the contract (burn at most 5000 bps, sink at most 3000 bps),
|
||||
and any change to the sink destination is time-locked for 7 days. If the sink
|
||||
is unset, its share is added to the validator rebate instead. Everything is
|
||||
readable on-chain; nothing here is off-book.
|
||||
|
||||
There is no staking yield, because there is no staking. A delegation UI tied to
|
||||
the sAERE flywheel is on the roadmap but is not live today; do not assume any
|
||||
delegation mechanics until the Foundation announces them.
|
||||
|
||||
---
|
||||
|
||||
## 7b. If you are being admitted as a validator: the validator properties
|
||||
|
||||
Everything above gets you a **following node**, and that is the normal case until the vote. A
|
||||
validator passes, through `BESU_OPTS`, the whole line of `docker-compose.yml` (it is derived from a
|
||||
running validator of the fleet and carries the validators' own seal cap) plus the properties below.
|
||||
The Foundation confirms the exact values when your admission is scheduled.
|
||||
|
||||
| property | what it is |
|
||||
|---|---|
|
||||
| `aere.falcon.key` | the path of YOUR Falcon-512 key file, written by `validator-keys.sh` |
|
||||
| `aere.pq.hybrid.key.slh-dsa-sha2-128s` | the path of YOUR SLH-DSA-SHA2-128s key file, written by `validator-keys.sh` |
|
||||
| `aere.pq.registryV3`, `aere.pq.registryV3.hashes` | every registry epoch and its pin, including the one that carries your row (section 2) |
|
||||
| `aere.pq.hybrid.attachBlock`, `aere.pq.proposalPq.attachBlock`, `aere.pq.preparePq.attachBlock`, `aere.pq.roundChangePq.attachBlock` | the heights from which the node EMITS post-quantum seals; already in `docker-compose.yml` |
|
||||
|
||||
Two things a validator must NOT carry: `aere.pq.anchor.minSealsCeiling` (a node syncing from genesis
|
||||
with `run-node.sh` sets it to 1 to start inside the small genesis set; a validator starts into the full
|
||||
set and must judge anchors at the scheduled threshold), and a node key from anywhere else than its data
|
||||
directory (section 3). From block 20,836,228 the key a node signs with, and the index it signs under, are
|
||||
looked up in the registry epoch in force by the PUBLIC key, and the client refuses to start a validator
|
||||
registered in an epoch whose row does not match the key it holds (`AERE-PQC-KEY-NEXT-02`).
|
||||
|
||||
**One path not yet measured, stated so you do not lose an evening to it.** A node that synced as a
|
||||
follower and restarts as a validator passes the client's attach-height guard when it finds a recent
|
||||
Falcon-sealed header; otherwise it refuses to start with `AERE-PQC-CFG-UNSAFE-05`, and the recovery
|
||||
is `aere.falcon.attachBlockAcknowledgePast=<attachBlock>` for one restart. Both behaviours were measured
|
||||
on 2026-08-12 on the fleet of that time; for a node joining after block 20,836,228 they have not been
|
||||
measured yet (the Foundation measures them on its rehearsal network before the first admission). If you
|
||||
hit that refusal, send the log line; do not work around it alone: an attachment height that disagrees
|
||||
with the fleet is a liveness incident, not a local setting.
|
||||
|
||||
**One more measured fact, because it decides how you schedule your upgrades.** If
|
||||
your node is a validator and it is still on a build without a rule the fleet has armed, it does
|
||||
not fork and it does not accept anything wrong. But every time it is proposer at
|
||||
an anchor height, the upgraded majority rejects its block and the round is lost.
|
||||
Measured with two of seven nodes un-upgraded (2026-08): the chain head paused for about
|
||||
twenty seconds across a hundred blocks. Being behind costs the network rhythm, not
|
||||
safety.
|
||||
|
||||
### 7c. Post-quantum seals on consensus messages (live since September 2026)
|
||||
|
||||
Every 32nd block carries a post-quantum certificate under its hash. The blocks between anchors carry
|
||||
no Falcon seal of their own, and their validity at import rests on ECDSA, so the measured depth a
|
||||
fabricated segment could reach before an anchor stops it is **31 consecutive blocks**.
|
||||
|
||||
Beyond the anchors, each of the four QBFT messages (proposal, prepare, commit, round-change) carries a
|
||||
Falcon-512 seal of its author, each under its own domain. On chain 2800 the nine Besu-derived
|
||||
validators refuse a message without a valid seal: COMMIT from block 17,250,000, PROPOSAL from
|
||||
17,400,000, ROUND-CHANGE from 17,550,000 and PREPARE from 17,700,000. The tenth validator, which runs
|
||||
the second client, refuses a COMMIT without one from block 18,131,633 and applies the other three
|
||||
types from AIP-22 stage 4, whose height is not yet set. Each message has two switches, passed like
|
||||
the anchor properties:
|
||||
|
||||
| property | what it does |
|
||||
|---|---|
|
||||
| `aere.pq.<message>Pq.attachBlock` | height from which **this node emits** a seal on that message |
|
||||
| `aere.pq.<message>Pq.forkBlock` | height from which **this node refuses** that message without a valid seal |
|
||||
|
||||
where `<message>` is one of `prepare`, `proposal`, `roundChange` (commit uses
|
||||
`aere.pq.commitPq.forkBlock`).
|
||||
|
||||
**Two things worth knowing, both measured:**
|
||||
|
||||
- **Emission and enforcement are separate on purpose.** The fleet emitted for a while before
|
||||
anything refused. If they were one switch, the first node to start emitting would also start
|
||||
refusing, and that is a flag day.
|
||||
- **A mistyped value refuses to start, it does not quietly disarm.** If you set one of these to
|
||||
something that is not a plain block height, the client stops with a configuration error: a node
|
||||
that boots disarmed because of a stray character looks exactly like a correctly configured one,
|
||||
right up to the day it matters.
|
||||
|
||||
**What this asks of you as a validator:** your post-quantum keys (Falcon-512, and SLH-DSA for the
|
||||
hybrid anchor certificate), generated by you on your machine and carried in the registry epoch that
|
||||
admits you, and the same emission and enforcement heights as the fleet, which the Foundation gives you.
|
||||
A validator whose seals are refused does not fork and does not accept anything wrong: it loses its
|
||||
turn as proposer, and the chain continues without it as long as a quorum of the others can seal.
|
||||
|
||||
**What we do not claim.** Per-block finality on chain 2800 still rests on classical ECDSA, so AERE is
|
||||
not a "fully post-quantum consensus" and we do not describe it as one. What you can verify yourself,
|
||||
without taking our word for it, is the anchor certificate at every 32nd block, with the public
|
||||
verifier.
|
||||
|
||||
---
|
||||
|
||||
## 8. Day-two operations
|
||||
|
||||
- **Updates:** the live network runs a pinned build of the **Aere fork** of Besu,
|
||||
not stock upstream. Every new fleet build is published as a patch in `aere-node` before it is
|
||||
required, with the height from which it is needed; rebuild with `./run-node.sh --no-start --rebuild`
|
||||
and restart before that height. Do not move to `:latest` on your own.
|
||||
- **Restarts:** tell the Foundation before a planned restart. A restarted validator starts with an empty
|
||||
post-quantum seal store, and two validator restarts within minutes of each other can stall an anchor
|
||||
round. A node restarting with its `data/` intact keeps its identity and does not re-hit the 5-peer
|
||||
trap.
|
||||
- **Emergencies:** the emergency settings of the anchor rules are applied uniformly to every node, never
|
||||
to one; when the Foundation announces one, it applies to your node too, at the same time.
|
||||
- **Backups:** back up `data/key` and the two post-quantum key files. Those are the irreplaceable files.
|
||||
- **Monitoring:** scrape Prometheus metrics on `127.0.0.1:9545`. Track block
|
||||
height versus the public head, peer count, disk free space (Bonsai growth) and the clock against the
|
||||
chain head (section 3b).
|
||||
- **Disk:** watch free space. At sub-second blocks storage grows steadily; plan
|
||||
for a growable NVMe volume.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Network name | AERE Network |
|
||||
| Chain ID / network ID | 2800 |
|
||||
| Consensus | Hyperledger Besu, QBFT |
|
||||
| Block time | sub-second since block 2,137,652 (0.62 s average over the 100,000 blocks before 2026-10-15) |
|
||||
| Gas price | min-gas-price 0 on the node; the protocol base-fee floor sets the price |
|
||||
| Storage format | Bonsai |
|
||||
| Client | **Aere fork of Besu**, built from the public patch series of the `aere-node` bundle (0001 to 0010 as of 2026-10-14) on the named upstream commit. Stock upstream cannot follow chain 2800 past block 9,189,161. See RUN-A-NODE.md in `aere-node`. |
|
||||
| Required JVM flags | the full `BESU_OPTS` line in `docker-compose.yml` (base-fee floor, post-quantum anchor, registry epochs, post-quantum identities, post-quantum transactions); a node missing one stops at the first height that needs it |
|
||||
| Validator keys | node key `data/key` (also the consensus key), Falcon-512 and SLH-DSA-SHA2-128s; all generated by the operator (`validator/validator-keys.sh` in `aere-node`) |
|
||||
| Candidacy | `validator/validator-candidacy.sh` in `aere-node`: registry row, enode, proof of possession, signed clock report |
|
||||
| Clock | at most 1 s behind and 2 s ahead of the chain head, measured against the head |
|
||||
| JVM heap | `-Xmx4g` |
|
||||
| P2P port | 30303 tcp + udp (public) |
|
||||
| RPC port | 8545 (keep private; on a validator, localhost only) |
|
||||
| Metrics port | 9545 (keep private) |
|
||||
| Active validators today | 10 (Foundation), as read from the chain on 2026-10-15 |
|
||||
| Fault tolerance | f = 3, quorum 7-of-10 (as read from the chain at 2026-09-29 07:55 UTC) |
|
||||
| Public path | 10 to 21 |
|
||||
| Staking / bond / slashing | None |
|
||||
| Admission | Governance vote of existing validators, after the registry epoch that carries your row |
|
||||
| Reward contract | AereCoinbaseSplitterV2 `0x8C1A48eFA57b66fEE743A00E3899c29ad3Fd27b4` |
|
||||
| Genesis hash | Provided by the Foundation on application |
|
||||
| Bootnode enode | Provided by the Foundation on application |
|
||||
| `static-nodes.json` | Provided by the Foundation on application |
|
||||
|
||||
Live consensus explainer: `https://aere.network/consensus`
|
||||
Live validator state: `https://aere.network/validators`
|
||||
90
validator/kit-common.sh
Normal file
90
validator/kit-common.sh
Normal file
@ -0,0 +1,90 @@
|
||||
# kit-common.sh - shared by validator-keys.sh and validator-candidacy.sh (sourced, not run).
|
||||
#
|
||||
# Everything here runs on the candidate's machine. It needs bash, a JDK 21 (the same one run-node.sh
|
||||
# requires), coreutils and curl. It holds no key and sends nothing anywhere.
|
||||
|
||||
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUNDLE_DIR="$(cd "$KIT_DIR/.." && pwd)"
|
||||
|
||||
say() { printf '\n==> %s\n' "$*"; }
|
||||
ok() { printf ' OK %s\n' "$*"; }
|
||||
note() { printf ' %s\n' "$*"; }
|
||||
die() { printf '\nREFUSED: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
|
||||
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
|
||||
else die "no sha256sum or shasum on PATH"; fi
|
||||
}
|
||||
|
||||
# mode of a file or directory, as three octal digits (GNU stat, then BSD stat)
|
||||
mode_of() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null; }
|
||||
|
||||
# A private file must be readable by its owner only (600 or 400); a key directory 700 or 500.
|
||||
need_private_file() {
|
||||
local m; m="$(mode_of "$1")"
|
||||
case "$m" in 600|400) ok "$1 is owner-only ($m)";; *) die "$1 has mode ${m:-unknown}; a private key file must be owner-only. Run: chmod 600 '$1'";; esac
|
||||
}
|
||||
need_private_dir() {
|
||||
local m; m="$(mode_of "$1")"
|
||||
case "$m" in 700|500) ok "$1 is owner-only ($m)";; *) die "$1 has mode ${m:-unknown}; the key directory must be owner-only. Run: chmod 700 '$1'";; esac
|
||||
}
|
||||
|
||||
# kit_find_lib <build dir>: sets LIB to the lib/ of a build made from the PUBLISHED patch series, or refuses.
|
||||
# The registry tool and the signing helper must be the classes the fleet runs (class for class, published
|
||||
# 2026-10-14 for patch 0010); a build from another patch set is refused exactly as run-node.sh refuses to
|
||||
# reuse it.
|
||||
kit_find_lib() {
|
||||
local b="$1"
|
||||
command -v java >/dev/null 2>&1 || die "java not found; JDK 21 is required (the same one run-node.sh uses)"
|
||||
local v; v="$(java -version 2>&1 | head -1 | sed -E 's/.*version "([0-9]+).*/\1/')"
|
||||
[ "$v" = "21" ] || die "JDK 21 is required, found: $(java -version 2>&1 | head -1)"
|
||||
LIB="$b/build/install/besu/lib"
|
||||
[ -d "$LIB" ] || die "no build at $LIB. Build the node first: ./run-node.sh --no-start (it builds from the published patch series)"
|
||||
ls "$LIB"/besu-consensus-common-*.jar >/dev/null 2>&1 || die "$LIB has no besu-consensus-common jar; this is not a Besu build"
|
||||
local want got p
|
||||
# (every pipeline below that may legitimately find nothing ends in `|| true`: under pipefail an empty grep would otherwise end
|
||||
# the script silently, and the emptiness is then refused with a message)
|
||||
want="$(sed -n '/^PATCHES=(/,/^)/p' "$BUNDLE_DIR/run-node.sh" | grep -oE '"[0-9]{4}-[A-Za-z0-9._-]+\.patch"' | tr -d '"' \
|
||||
| while read -r p; do printf '%s %s\n' "$(sha256_of "$BUNDLE_DIR/patches/$p" < /dev/null)" "$p"; done || true)"
|
||||
[ -n "$want" ] || die "cannot read the patch list from run-node.sh"
|
||||
got="$(cat "$b/.aere-patchset" 2>/dev/null || true)"
|
||||
[ "$got" = "$want" ] || die "the build at $b was not made from this bundle's patch series (or the marker .aere-patchset is missing).
|
||||
Rebuild with: ./run-node.sh --no-start --rebuild"
|
||||
ok "build from the published patch series: $LIB"
|
||||
local u
|
||||
u="$(java -Xmx256m -cp "$LIB/*" org.hyperledger.besu.consensus.common.bft.tools.PqRegistryV3Tool 2>&1 < /dev/null | head -2 || true)"
|
||||
case "$u" in *usage*PqRegistryV3Tool*) ok "registry tool PqRegistryV3Tool present (patch 0008)";;
|
||||
*) die "the build has no PqRegistryV3Tool (patch 0008); it cannot make a registry row";; esac
|
||||
}
|
||||
|
||||
kit_java() { java -Xmx512m -cp "$LIB/*" "$KIT_DIR/AereCandidacy.java" "$@"; }
|
||||
|
||||
# The private values of a key set, one per line, for a FIXED-STRING search only (never printed, never
|
||||
# written to disk: they go through a pipe into grep -f). Hex digits only, without 0x.
|
||||
# NO EMPTY LINE ever comes out: an empty fixed-string pattern matches every line, and the check would refuse everything (or, used
|
||||
# the other way round, find everything).
|
||||
kit_private_values() { # falcon-key slh-key node-key
|
||||
{
|
||||
[ -f "$1" ] && grep -E '^(f|g|F)=' "$1" | cut -d= -f2- | tr -d '\r '
|
||||
[ -f "$2" ] && grep -E '^sk=' "$2" | cut -d= -f2- | tr -d '\r '
|
||||
[ -f "$3" ] && { tr -d '\r\n ' < "$3" | sed 's/^0x//'; echo; }
|
||||
} | grep -E '^[0-9a-fA-F]{16,}$' || true
|
||||
}
|
||||
# kit_no_private_in <falcon-key> <slh-key> <node-key> <file>...: refuses if any private value appears in any of the files.
|
||||
kit_no_private_in() {
|
||||
local fk="$1" sk="$2" nk="$3"; shift 3
|
||||
local n
|
||||
n="$(kit_private_values "$fk" "$sk" "$nk" | grep -c . || true)"
|
||||
[ "$n" -ge 4 ] || die "the private-value check could not read the key files (got $n values, expected at least 4); nothing is trusted"
|
||||
# control of the method: the search must FIND the values in the key file itself, or a zero below means nothing
|
||||
local self; self="$(grep -c -i -F -f <(kit_private_values "$fk" "$sk" "$nk") "$fk" || true)"
|
||||
[ "${self:-0}" -ge 3 ] || die "the private-value check does not find the values even in the key file (method blind); nothing is trusted"
|
||||
local f hits
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || continue
|
||||
hits="$(grep -c -i -F -f <(kit_private_values "$fk" "$sk" "$nk") "$f" || true)"
|
||||
[ "${hits:-0}" = 0 ] || die "a private key value appears in $f. Do not send or publish it; delete it and report this."
|
||||
done
|
||||
ok "no private key value in: $(printf '%s ' "$@" | sed 's#[^ ]*/##g')"
|
||||
}
|
||||
145
validator/validator-candidacy.sh
Normal file
145
validator/validator-candidacy.sh
Normal file
@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# validator-candidacy.sh - write the candidacy file a chain-2800 validator candidate sends to the
|
||||
# Foundation: the registry row, the enode, a proof of possession of all three keys, and a signed
|
||||
# measurement of this machine's clock against the chain head. PUBLIC data only.
|
||||
#
|
||||
# What it does, in order, on THIS machine:
|
||||
# 1. checks that the build is from the published patch series and that the three keys are in place,
|
||||
# owner-only (validator-keys.sh made the two post-quantum ones);
|
||||
# 2. asks YOUR running node (its local RPC) for its enode, and refuses if the node runs with another
|
||||
# key than <data-path>/key: the consensus key must be the node key;
|
||||
# 3. measures the clock: the chain head's timestamp against this machine's clock, several times. Chain
|
||||
# 2800 rejects a header more than ONE second ahead of the node's clock
|
||||
# (TimestampBoundedByFutureParameter(1)): a node whose clock is behind refuses the head blocks and
|
||||
# disconnects its peers (BREACH_OF_PROTOCOL); a node whose clock is ahead makes the others refuse
|
||||
# ITS blocks. Refuses outside [-2 s ahead, 1 s behind];
|
||||
# 4. writes the registry row with PqRegistryV3Tool row (your build, patch 0008): the id derived from
|
||||
# your Falcon key, both public keys, a proof of possession by each, the link signed by the node key,
|
||||
# and transport = the node key's address;
|
||||
# 5. signs the candidacy message with all three keys (validator/README.md, "The candidacy file"),
|
||||
# verifies its own signatures, and writes the candidacy file;
|
||||
# 6. checks that no private key value appears in anything it wrote or printed.
|
||||
#
|
||||
# Usage:
|
||||
# validator/validator-candidacy.sh --params <admission-params.json> --enode-host <host> [options]
|
||||
# validator/validator-candidacy.sh --clock-only --params <admission-params.json> --enode-host <host> [options]
|
||||
# (on the day of the vote: only a fresh, signed clock report, written to --out)
|
||||
#
|
||||
# --enode-host <host> the public host or IP at which the validators reach your node (P2P)
|
||||
# --p2p-port <n> your P2P port. Default: 30303
|
||||
# --rpc <url> your node's LOCAL JSON-RPC. Default: http://127.0.0.1:8545
|
||||
# --build-dir <dir> Default: ./.besu-build next to run-node.sh
|
||||
# --data-path <dir> Default: ./aere-data next to run-node.sh
|
||||
# --keys-dir <dir> Default: $HOME/.aere-validator/keys
|
||||
# --samples <n> clock samples, two seconds apart. Default: 12
|
||||
# --out <file> Default: ./aere-candidacy.json (./aere-clock-report.json with --clock-only)
|
||||
# -h | --help
|
||||
#
|
||||
set -euo pipefail
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/kit-common.sh"
|
||||
|
||||
PARAMS=""; HOSTP=""; P2P=30303; RPC="http://127.0.0.1:8545"; BUILD_DIR="$BUNDLE_DIR/.besu-build"; DATA_PATH="$BUNDLE_DIR/aere-data"
|
||||
KEYS_DIR="${HOME}/.aere-validator/keys"; SAMPLES=12; OUT=""; CLOCK_ONLY=0
|
||||
usage() { awk 'NR>=2 && /^#/{sub(/^# ?/,"");print;next} NR>=2{exit}' "${BASH_SOURCE[0]}"; exit 0; }
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--params) PARAMS="${2:-}"; shift 2 ;;
|
||||
--enode-host) HOSTP="${2:-}"; shift 2 ;;
|
||||
--p2p-port) P2P="${2:-}"; shift 2 ;;
|
||||
--rpc) RPC="${2:-}"; shift 2 ;;
|
||||
--build-dir) BUILD_DIR="${2:-}"; shift 2 ;;
|
||||
--data-path) DATA_PATH="${2:-}"; shift 2 ;;
|
||||
--keys-dir) KEYS_DIR="${2:-}"; shift 2 ;;
|
||||
--samples) SAMPLES="${2:-}"; shift 2 ;;
|
||||
--out) OUT="${2:-}"; shift 2 ;;
|
||||
--clock-only) CLOCK_ONLY=1; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) die "unknown argument: $1 (try --help)" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PARAMS" ] && [ -f "$PARAMS" ] || die "--params <admission-params.json> is required"
|
||||
[ -n "$HOSTP" ] || die "--enode-host <your public P2P host or IP> is required"
|
||||
[ "$SAMPLES" -ge 3 ] 2>/dev/null || die "--samples must be a number >= 3"
|
||||
[ -n "$OUT" ] || { [ "$CLOCK_ONLY" = 1 ] && OUT="./aere-clock-report.json" || OUT="./aere-candidacy.json"; }
|
||||
[ -e "$OUT" ] && die "$OUT exists; it is never overwritten"
|
||||
command -v curl >/dev/null 2>&1 || die "curl not found"
|
||||
|
||||
say "1/6 The build and the keys"
|
||||
kit_find_lib "$BUILD_DIR"
|
||||
read -r CHAIN BIND COUNT INDEX < <(kit_java params --params "$PARAMS" 2>/dev/null) || die "the admission parameters do not parse"
|
||||
[ -n "${INDEX:-}" ] || die "the admission parameters do not parse"
|
||||
ok "chain $CHAIN, epoch bound at $BIND, $COUNT rows, your index $INDEX"
|
||||
NK="$DATA_PATH/key"; FK="$KEYS_DIR/falcon-512.key"; SK="$KEYS_DIR/slh-dsa-sha2-128s.key"
|
||||
[ -f "$NK" ] && [ -f "$FK" ] && [ -f "$SK" ] || die "keys missing ($NK, $FK, $SK); run validator/validator-keys.sh first"
|
||||
need_private_dir "$KEYS_DIR"; need_private_file "$NK"; need_private_file "$FK"; need_private_file "$SK"
|
||||
W="$(mktemp -d "$KEYS_DIR/.candidacy.XXXXXX")"; trap 'rm -rf "$W"' EXIT
|
||||
kit_java ids --node-key-file "$NK" --falcon-key "$FK" --slh-key "$SK" > "$W/ids.json" 2> "$W/ids.log" || die "the keys do not form one identity (see $W/ids.log)"
|
||||
NODEPUB="$(grep -o '"nodePublicKey" : "0x[0-9a-f]*"' "$W/ids.json" | grep -o '0x[0-9a-f]*' | sed 's/^0x//' || true)"
|
||||
[ "${#NODEPUB}" = 128 ] || die "could not derive the node public key"
|
||||
|
||||
rpc() { curl -s -m 10 -H 'content-type: application/json' --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":$2}" "$RPC" 2>/dev/null || true; }
|
||||
say "2/6 Your node: the key it runs with"
|
||||
R="$(rpc net_enode '[]')"
|
||||
RUNPUB="$(printf '%s' "$R" | grep -oE 'enode://[0-9a-f]{128}' | head -1 | sed 's#enode://##' || true)"
|
||||
[ -n "$RUNPUB" ] || die "your node does not answer net_enode at $RPC (start it with run-node.sh; the NET API is enabled there). A silent node is not a measurement"
|
||||
[ "$RUNPUB" = "$NODEPUB" ] || die "your running node uses ANOTHER node key than $NK. The consensus key must be the node key: a validator whose node key differs receives no consensus message. Start the node with --data-path $DATA_PATH"
|
||||
CID="$(rpc eth_chainId '[]' | grep -oE '"result":"0x[0-9a-f]+"' | grep -oE '0x[0-9a-f]+' || true)"
|
||||
[ -n "$CID" ] && [ "$((CID))" = "$CHAIN" ] || die "your node is not on chain $CHAIN (eth_chainId says ${CID:-nothing})"
|
||||
ok "the node at $RPC runs with the key in $NK (enode public key matches), chain $CHAIN"
|
||||
|
||||
say "3/6 The clock, against the chain head ($SAMPLES samples)"
|
||||
SY="$(rpc eth_syncing '[]')"
|
||||
printf '%s' "$SY" | grep -q '"result":false' || die "your node is still syncing; measure the clock when it follows the head"
|
||||
DMIN=""; DMAX=""; HEAD=0
|
||||
for i in $(seq 1 "$SAMPLES"); do
|
||||
B="$(rpc eth_getBlockByNumber '["latest",false]')"
|
||||
NOW="$(date +%s)"
|
||||
TS="$(printf '%s' "$B" | grep -oE '"timestamp":"0x[0-9a-f]+"' | grep -oE '0x[0-9a-f]+' | head -1 || true)"
|
||||
NUM="$(printf '%s' "$B" | grep -oE '"number":"0x[0-9a-f]+"' | grep -oE '0x[0-9a-f]+' | head -1 || true)"
|
||||
[ -n "$TS" ] && [ -n "$NUM" ] || die "the node did not return the head block (sample $i); an empty answer is not a measurement"
|
||||
D=$(( TS - NOW )); HEAD=$((NUM))
|
||||
if [ -z "$DMIN" ] || [ "$D" -lt "$DMIN" ]; then DMIN=$D; fi
|
||||
if [ -z "$DMAX" ] || [ "$D" -gt "$DMAX" ]; then DMAX=$D; fi
|
||||
if [ "$i" -lt "$SAMPLES" ]; then sleep 2; fi
|
||||
done
|
||||
AT="$(date +%s)"
|
||||
note "head timestamp minus local clock: from $DMIN to $DMAX s over $SAMPLES samples (head $HEAD)"
|
||||
[ "$DMAX" -le 1 ] || die "your clock is BEHIND the chain by up to $DMAX s. A node more than one second behind rejects the head blocks (TimestampBoundedByFutureParameter(1)) and disconnects its peers with BREACH_OF_PROTOCOL. Fix the clock (NTP or chrony), then measure again"
|
||||
[ "$DMIN" -ge -2 ] || die "the head is $(( -DMIN )) s older than your clock: your clock is AHEAD, or your node is lagging. A validator whose clock runs ahead proposes blocks the others refuse. Fix the clock or let the node catch up, then measure again"
|
||||
ok "clock within the margin (at most 1 s behind, at most 2 s ahead)"
|
||||
|
||||
if [ "$CLOCK_ONLY" = 1 ]; then
|
||||
say "4/4 The signed clock report"
|
||||
kit_java clock-report --chain-id "$CHAIN" --node-key-file "$NK" --enode-host "$HOSTP" --p2p-port "$P2P" \
|
||||
--clock-measured-at "$AT" --clock-head "$HEAD" --clock-samples "$SAMPLES" --clock-dmin "$DMIN" --clock-dmax "$DMAX" \
|
||||
--out "$OUT" > "$W/out.json" 2> "$W/out.log" || die "the clock report was not written (see the message above)"
|
||||
kit_no_private_in "$FK" "$SK" "$NK" "$OUT" "$W/out.json" "$W/out.log"
|
||||
sed 's/^/ /' "$W/out.json"
|
||||
printf '\n Send %s to the Foundation (it holds only public data and one signature by your node key).\n' "$OUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say "4/6 The registry row (PqRegistryV3Tool row, your build)"
|
||||
SLHPUB="$(grep -E '^pk=' "$SK" | cut -d= -f2 | tr -d '\r ' || true)"
|
||||
[ "${#SLHPUB}" = 64 ] || die "the SLH-DSA key file has no 32-byte public key"
|
||||
java -Xmx512m -cp "$LIB/*" org.hyperledger.besu.consensus.common.bft.tools.PqRegistryV3Tool row --chain-id "$CHAIN" --bind "$BIND" \
|
||||
--count "$COUNT" --index "$INDEX" --falcon-key "$FK" --slh-key "$SK" --slh-pub "0x$SLHPUB" --ecdsa-key-file "$NK" --transport \
|
||||
> "$W/row.json" 2> "$W/row.log" < /dev/null || die "PqRegistryV3Tool row failed (see the tool's message in $W/row.log before it is removed: $(head -c 300 "$W/row.log" | tr '\n' ' '))"
|
||||
ok "row written for index $INDEX"
|
||||
|
||||
say "5/6 The candidacy proof and file"
|
||||
kit_java candidacy --params "$PARAMS" --row "$W/row.json" --falcon-key "$FK" --slh-key "$SK" --node-key-file "$NK" \
|
||||
--enode-host "$HOSTP" --p2p-port "$P2P" --issued "$(date -u +%F)" --clock-measured-at "$AT" --clock-head "$HEAD" \
|
||||
--clock-samples "$SAMPLES" --clock-dmin "$DMIN" --clock-dmax "$DMAX" --out "$OUT" > "$W/out.json" 2> "$W/out.log" \
|
||||
|| die "the candidacy was not written: $(grep -m1 REFUSED "$W/out.log" || head -c 300 "$W/out.log")"
|
||||
|
||||
say "6/6 Nothing private in what was written"
|
||||
kit_no_private_in "$FK" "$SK" "$NK" "$OUT" "$W/row.json" "$W/row.log" "$W/out.json" "$W/out.log" "$W/ids.json" "$W/ids.log"
|
||||
sed 's/^/ /' "$W/out.json"
|
||||
cat <<EOF
|
||||
|
||||
Send $OUT to the Foundation. It holds your registry row, your enode, three signatures and a
|
||||
signed clock measurement: public data only. The Foundation verifies it independently
|
||||
(verifica-candidatura) before your row is put into a registry epoch.
|
||||
EOF
|
||||
99
validator/validator-keys.sh
Normal file
99
validator/validator-keys.sh
Normal file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# validator-keys.sh - generate a chain-2800 validator's post-quantum keys ON THIS MACHINE.
|
||||
#
|
||||
# A validator of Aere Network holds three keys, and all three are generated by the operator, never by
|
||||
# the Foundation and never anywhere else:
|
||||
#
|
||||
# 1. secp256k1, the NODE key (<data-path>/key). It is also the consensus key: Besu sends consensus
|
||||
# messages only to peers whose node-key address is in the validator set, so a validator whose
|
||||
# node key is not its validator key receives no consensus message at all. The node creates this
|
||||
# key the first time it starts (run-node.sh); this script uses it and does not replace it.
|
||||
# 2. Falcon-512, the key behind every post-quantum seal (aere.falcon.key).
|
||||
# 3. SLH-DSA-SHA2-128s, the second scheme of the hybrid anchor certificate
|
||||
# (aere.pq.hybrid.key.slh-dsa-sha2-128s).
|
||||
#
|
||||
# 2 and 3 are generated by PqRegistryV3Tool keygen, the tool inside YOUR build of the published patch
|
||||
# series (patch 0008): owner-only files, never overwritten, only the public keys printed.
|
||||
#
|
||||
# Nothing private is printed, logged or sent. The script checks its own output for that before it ends.
|
||||
#
|
||||
# Usage:
|
||||
# validator/validator-keys.sh --params <admission-params.json> [options]
|
||||
#
|
||||
# --params <file> the admission parameters the Foundation sends you (chainId, bindHeight, count,
|
||||
# index): public, and the index is written into the key files
|
||||
# --build-dir <dir> the build made by run-node.sh. Default: ./.besu-build next to run-node.sh
|
||||
# --data-path <dir> the node's data directory (holds the node key). Default: ./aere-data next to run-node.sh
|
||||
# --keys-dir <dir> where the two post-quantum keys are written (created owner-only).
|
||||
# Default: $HOME/.aere-validator/keys (outside the repository, on purpose)
|
||||
# --create-node-key create <data-path>/key with Besu's own KeyPairUtil if it does not exist yet
|
||||
# (the usual way is to start the node once; it creates the key itself)
|
||||
# -h | --help
|
||||
#
|
||||
set -euo pipefail
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/kit-common.sh"
|
||||
|
||||
PARAMS=""; BUILD_DIR="$BUNDLE_DIR/.besu-build"; DATA_PATH="$BUNDLE_DIR/aere-data"; KEYS_DIR="${HOME}/.aere-validator/keys"; CREATE=0
|
||||
usage() { awk 'NR>=2 && /^#/{sub(/^# ?/,"");print;next} NR>=2{exit}' "${BASH_SOURCE[0]}"; exit 0; }
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--params) PARAMS="${2:-}"; shift 2 ;;
|
||||
--build-dir) BUILD_DIR="${2:-}"; shift 2 ;;
|
||||
--data-path) DATA_PATH="${2:-}"; shift 2 ;;
|
||||
--keys-dir) KEYS_DIR="${2:-}"; shift 2 ;;
|
||||
--create-node-key) CREATE=1; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) die "unknown argument: $1 (try --help)" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PARAMS" ] && [ -f "$PARAMS" ] || die "--params <admission-params.json> is required (the Foundation sends it)"
|
||||
|
||||
say "1/4 The build"
|
||||
kit_find_lib "$BUILD_DIR"
|
||||
|
||||
say "2/4 The admission parameters"
|
||||
read -r CHAIN BIND COUNT INDEX < <(kit_java params --params "$PARAMS" 2>/dev/null) || die "the admission parameters do not parse (run: java -cp '$LIB/*' $KIT_DIR/AereCandidacy.java params --params $PARAMS)"
|
||||
[ -n "${INDEX:-}" ] || die "the admission parameters do not parse"
|
||||
ok "chain $CHAIN, registry epoch bound at block $BIND, $COUNT rows, your row index $INDEX"
|
||||
|
||||
say "3/4 The node key (secp256k1, also the consensus key)"
|
||||
NK="$DATA_PATH/key"
|
||||
if [ ! -f "$NK" ]; then
|
||||
[ "$CREATE" = 1 ] || die "$NK does not exist. Start your node once with run-node.sh (it creates the key), stop it, and run this again; or pass --create-node-key"
|
||||
[ -d "$DATA_PATH" ] || (umask 077; mkdir -p "$DATA_PATH")
|
||||
(umask 077; kit_java node-key --data-path "$DATA_PATH" > /dev/null 2>&1) || die "the node key could not be created with Besu's KeyPairUtil"
|
||||
ok "created $NK with Besu's own KeyPairUtil"
|
||||
fi
|
||||
need_private_file "$NK"
|
||||
|
||||
say "4/4 The post-quantum keys"
|
||||
FK="$KEYS_DIR/falcon-512.key"; SK="$KEYS_DIR/slh-dsa-sha2-128s.key"
|
||||
if [ ! -d "$KEYS_DIR" ]; then (umask 077; mkdir -p "$KEYS_DIR"); fi
|
||||
need_private_dir "$KEYS_DIR"
|
||||
if [ -f "$FK" ] && [ -f "$SK" ]; then
|
||||
note "both keys already exist in $KEYS_DIR; they are NOT regenerated (a key is never replaced by this script)"
|
||||
elif [ -f "$FK" ] || [ -f "$SK" ]; then
|
||||
die "only one of the two keys exists in $KEYS_DIR; refusing to guess. Move the directory aside and run again"
|
||||
else
|
||||
(umask 077; java -Xmx512m -cp "$LIB/*" org.hyperledger.besu.consensus.common.bft.tools.PqRegistryV3Tool keygen \
|
||||
--index "$INDEX" --out-falcon "$FK" --out-slh "$SK" > "$KEYS_DIR/keygen-public.json" 2> "$KEYS_DIR/keygen.log" < /dev/null) \
|
||||
|| die "PqRegistryV3Tool keygen failed (see $KEYS_DIR/keygen.log)"
|
||||
ok "generated by PqRegistryV3Tool keygen in your build"
|
||||
fi
|
||||
need_private_file "$FK"; need_private_file "$SK"
|
||||
IDS_FILE="$(mktemp "$KEYS_DIR/.ids.XXXXXX")"; trap 'rm -f "$IDS_FILE"' EXIT
|
||||
kit_java ids --node-key-file "$NK" --falcon-key "$FK" --slh-key "$SK" > "$IDS_FILE" 2> "$KEYS_DIR/ids.log" \
|
||||
|| die "the keys do not form one identity (see $KEYS_DIR/ids.log)"
|
||||
grep -q "\"index\" : $INDEX\b" "$IDS_FILE" || die "the key files are not for index $INDEX (keys generated for another admission?)"
|
||||
kit_no_private_in "$FK" "$SK" "$NK" "$IDS_FILE" "$KEYS_DIR/keygen-public.json" "$KEYS_DIR/keygen.log" "$KEYS_DIR/ids.log"
|
||||
|
||||
say "Your validator identity (public; nothing private is shown)"
|
||||
sed 's/^/ /' "$IDS_FILE"
|
||||
cat <<EOF
|
||||
|
||||
The two private key files stay in $KEYS_DIR, owner-only. Back them up OFFLINE together with
|
||||
$NK: losing them means a new admission; leaking them lets someone sign as your validator.
|
||||
|
||||
Next: validator/validator-candidacy.sh --params $PARAMS --enode-host <your public P2P host>
|
||||
EOF
|
||||
Loading…
Reference in New Issue
Block a user