From cbe7f4691d1e0621ad67478d1f639f33b0eeaeed Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:46:22 +0000 Subject: [PATCH 1/5] Testnet seeds: NET=testnet profile for the seed scripts, seeds-testnet.tsv (3 Hetzner VMs), dns.sh (deSEC), the public RPC allowlist and nginx site, build-job.mjs forwards --node-tests seed1.testnet nbg1 195.201.35.33, seed2.testnet ash 5.161.232.205, seed3.testnet sin 5.223.52.210 (5 October 2026). Ports 26810/26811/28810/26890 from seed.env; provision-seed.sh BUILD_WHERE=cross takes the PC build job's Linux igneumd from infra/cross/out. Co-Authored-By: Claude Fable 5.1 --- infra/seed-nodes/config.sh | 19 ++- infra/seed-nodes/create-seed.sh | 16 +-- infra/seed-nodes/dns.sh | 37 ++++++ infra/seed-nodes/health.sh | 2 +- infra/seed-nodes/install-rpc-from-mac.sh | 22 ++++ infra/seed-nodes/lib.sh | 4 +- infra/seed-nodes/node/install-rpc.sh | 26 ++++ infra/seed-nodes/node/install-seed.sh | 9 +- infra/seed-nodes/node/run-seed.sh | 8 +- infra/seed-nodes/provision-seed.sh | 15 ++- .../seed-nodes/rpc/igneum-rpc-filter.service | 17 +++ infra/seed-nodes/rpc/nginx-rpc.conf | 29 +++++ infra/seed-nodes/rpc/rpc-filter.py | 116 ++++++++++++++++++ infra/seed-nodes/seeds-testnet.tsv | 3 + infra/seed-nodes/seeds-testnet.txt | 3 + tools/build-job.mjs | 6 +- 16 files changed, 307 insertions(+), 25 deletions(-) create mode 100755 infra/seed-nodes/dns.sh create mode 100755 infra/seed-nodes/install-rpc-from-mac.sh create mode 100755 infra/seed-nodes/node/install-rpc.sh create mode 100644 infra/seed-nodes/rpc/igneum-rpc-filter.service create mode 100644 infra/seed-nodes/rpc/nginx-rpc.conf create mode 100644 infra/seed-nodes/rpc/rpc-filter.py create mode 100644 infra/seed-nodes/seeds-testnet.tsv create mode 100644 infra/seed-nodes/seeds-testnet.txt diff --git a/infra/seed-nodes/config.sh b/infra/seed-nodes/config.sh index eafbddfc4..e43afae10 100755 --- a/infra/seed-nodes/config.sh +++ b/infra/seed-nodes/config.sh @@ -25,11 +25,22 @@ SSH_SOURCE="${SSH_SOURCE:-any}" HETZNER_TOKEN_FILE="${HETZNER_TOKEN_FILE:-$HOME/.config/igneum/hetzner-token}" # one line, the API token; never printed -NETWORK_ARGS="${NETWORK_ARGS:---devnet}" # the live devnet; a suffixed test network: "--devnet --devnet-suffix=20" +# NET picks the network profile (5 October 2026): devnet = the shared devnet on the 266xx ports, seeds.tsv, firewall +# igneum-seed; testnet = igneum-testnet-1 (--testnet --netsuffix=1) on the 268xx ports (docs/testnet/README.md section 1), +# seeds-testnet.tsv, firewall igneum-testnet-seed, DNS names seedN.testnet.igneum.network (dns.sh). +NET="${NET:-devnet}" +case "$NET" in + devnet) + NETWORK_ARGS="${NETWORK_ARGS:---devnet}" # the live devnet; a suffixed test network: "--devnet --devnet-suffix=20" + P2P_PORT=26611; RPC_PORT=26610; RPC_JSON_PORT=28610; EVM_RPC_PORT=26790 + FIREWALL_NAME="${FIREWALL_NAME:-igneum-seed}"; SEEDS_FILE_BASE=seeds; DNS_ZONE_SUB="" ;; + testnet) + NETWORK_ARGS="${NETWORK_ARGS:---testnet --netsuffix=1}" + P2P_PORT=26811; RPC_PORT=26810; RPC_JSON_PORT=28810; EVM_RPC_PORT=26890 + FIREWALL_NAME="${FIREWALL_NAME:-igneum-testnet-seed}"; SEEDS_FILE_BASE=seeds-testnet; DNS_ZONE_SUB="testnet" ;; + *) echo "NET must be devnet or testnet" >&2; exit 1 ;; +esac SEED_PEERS="${SEED_PEERS:-}" # other seeds to --addpeer, comma separated ip:port (filled from seeds.txt by provision) -P2P_PORT=26611 -RPC_PORT=26610 -RPC_JSON_PORT=28610 # The node source (shared with the 20-node network): vendor/igneum-node working tree plus igneum-pow NODE_SRC="${NODE_SRC:-$REPO/vendor/igneum-node}" diff --git a/infra/seed-nodes/create-seed.sh b/infra/seed-nodes/create-seed.sh index 8cf858285..3d0955d45 100755 --- a/infra/seed-nodes/create-seed.sh +++ b/infra/seed-nodes/create-seed.sh @@ -38,14 +38,14 @@ else if ! hcloud ssh-key describe "$SSH_KEY_NAME" >/dev/null 2>&1; then hcloud ssh-key create --name "$SSH_KEY_NAME" --public-key-from-file "$SSH_KEY_FILE.pub" >/dev/null; log "uploaded ssh key $SSH_KEY_NAME" fi - if ! hcloud firewall describe igneum-seed >/dev/null 2>&1; then - hcloud firewall create --name igneum-seed --label igneum=seed >/dev/null - hcloud firewall add-rule igneum-seed --direction in --protocol tcp --port 22 --source-ips "$ssh_cidr" --description ssh >/dev/null - hcloud firewall add-rule igneum-seed --direction in --protocol tcp --port "$P2P_PORT" --source-ips 0.0.0.0/0 --source-ips ::/0 --description igneum-p2p >/dev/null - hcloud firewall add-rule igneum-seed --direction in --protocol icmp --source-ips 0.0.0.0/0 --source-ips ::/0 --description ping >/dev/null - log "created firewall igneum-seed (in: 22 from $ssh_cidr, $P2P_PORT from anywhere, icmp)" + if ! hcloud firewall describe "$FIREWALL_NAME" >/dev/null 2>&1; then + hcloud firewall create --name "$FIREWALL_NAME" --label igneum=seed --label net="$NET" >/dev/null + hcloud firewall add-rule "$FIREWALL_NAME" --direction in --protocol tcp --port 22 --source-ips "$ssh_cidr" --description ssh >/dev/null + hcloud firewall add-rule "$FIREWALL_NAME" --direction in --protocol tcp --port "$P2P_PORT" --source-ips 0.0.0.0/0 --source-ips ::/0 --description igneum-p2p >/dev/null + hcloud firewall add-rule "$FIREWALL_NAME" --direction in --protocol icmp --source-ips 0.0.0.0/0 --source-ips ::/0 --description ping >/dev/null + log "created firewall $FIREWALL_NAME (in: 22 from $ssh_cidr, $P2P_PORT from anywhere, icmp)" fi - log "plan: $SEED_NAME, $SEED_TYPE, $IMAGE, $SEED_LOCATION, persistent primary IPv4, firewall igneum-seed" + log "plan: $SEED_NAME, $SEED_TYPE, $IMAGE, $SEED_LOCATION, persistent primary IPv4, firewall $FIREWALL_NAME" hcloud server-type describe "$SEED_TYPE" -o json | python3 -c ' import json, sys j = json.load(sys.stdin); loc = sys.argv[1] @@ -57,7 +57,7 @@ for p in j["prices"]: [ "${YES:-0}" = 1 ] || { printf 'create it now (billing starts) [type yes]: '; read -r a; [ "$a" = yes ] || die "not confirmed"; } if hcloud server describe "$SEED_NAME" >/dev/null 2>&1; then log "$SEED_NAME exists, reusing it"; else hcloud server create --name "$SEED_NAME" --type "$SEED_TYPE" --image "$IMAGE" --location "$SEED_LOCATION" \ - --ssh-key "$SSH_KEY_NAME" --firewall igneum-seed --label igneum=seed --label role=seed >/dev/null + --ssh-key "$SSH_KEY_NAME" --firewall "$FIREWALL_NAME" --label igneum=seed --label role=seed --label net="$NET" >/dev/null log "created $SEED_NAME" fi ip=$(hcloud server ip "$SEED_NAME") diff --git a/infra/seed-nodes/dns.sh b/infra/seed-nodes/dns.sh new file mode 100755 index 000000000..4a6d03497 --- /dev/null +++ b/infra/seed-nodes/dns.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# DNS for the seeds (5 October 2026): one A record per seed in seeds-testnet.tsv (seedN.testnet.igneum.network) and +# rpc.testnet.igneum.network at the first seed (the public JSON-RPC behind nginx, node/install-rpc.sh), through the +# deSEC API (igneum.network's nameservers are ns1.desec.io and ns2.desec.org; token at ~/.config/igneum/desec-token, +# never printed). Idempotent: an existing record set is replaced. NET=testnet ./dns.sh [--check] +. "$(dirname "$0")/lib.sh" +[ "$NET" = testnet ] || die "dns.sh is for NET=testnet (the devnet seed has no DNS name)" +DESEC_TOKEN_FILE="${DESEC_TOKEN_FILE:-$HOME/.config/igneum/desec-token}" +[ -s "$DESEC_TOKEN_FILE" ] || die "no token at $DESEC_TOKEN_FILE" +ZONE="igneum.network" +auth=(-H "Authorization: Token $(tr -d '[:space:]' < "$DESEC_TOKEN_FILE")" -H "Content-Type: application/json") +put() { # put ; deSEC answers 429 to more than about one write a second, so each call retries after a pause + local sub="$1" ip="$2" code try + for try in 1 2 3 4 5 6; do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${auth[@]}" -X PUT "https://desec.io/api/v1/domains/$ZONE/rrsets/$sub/A/" \ + -d "{\"subname\":\"$sub\",\"type\":\"A\",\"ttl\":3600,\"records\":[\"$ip\"]}") + if [ "$code" = 404 ]; then + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${auth[@]}" -X POST "https://desec.io/api/v1/domains/$ZONE/rrsets/" \ + -d "{\"subname\":\"$sub\",\"type\":\"A\",\"ttl\":3600,\"records\":[\"$ip\"]}") + fi + case "$code" in 200|201) log "$sub.$ZONE A $ip ($code)"; sleep 2; return 0 ;; 429) sleep $((try * 3)) ;; *) die "$sub.$ZONE: http $code" ;; esac + done + die "$sub.$ZONE: rate limited six times" +} +if [ "${1:-}" = --check ]; then + while IFS=$'\t' read -r name _ _ ip _; do [ -n "$name" ] || continue; printf '%s.%s -> %s (want %s)\n' "$name" "$ZONE" "$(dig +short "$name.$ZONE" @ns1.desec.io | tr '\n' ' ')" "$ip"; done < "$SEEDS_TSV" + printf 'rpc.%s.%s -> %s\n' "$DNS_ZONE_SUB" "$ZONE" "$(dig +short "rpc.$DNS_ZONE_SUB.$ZONE" @ns1.desec.io | tr '\n' ' ')" + exit 0 +fi +first="" +while IFS=$'\t' read -r name _ _ ip _; do + [ -n "$name" ] || continue + put "$name" "$ip" + [ -n "$first" ] || first="$ip" +done < "$SEEDS_TSV" +[ -n "$first" ] && put "rpc.$DNS_ZONE_SUB" "$first" +log "done; propagation: dig +short seed1.$DNS_ZONE_SUB.$ZONE" diff --git a/infra/seed-nodes/health.sh b/infra/seed-nodes/health.sh index 1eae95963..6c351812e 100755 --- a/infra/seed-nodes/health.sh +++ b/infra/seed-nodes/health.sh @@ -10,7 +10,7 @@ check_one() { local port=FAIL unit=? info= dag= peers=? known=? disk=? mem=? synced=? ver=? blocks=? headers=? sink=? if nc -z -w 5 "$ip" "$P2P_PORT" >/dev/null 2>&1; then port=open; fi local raw - raw=$(sssh "$ip" "if systemctl is-active igneumd-v4 >/dev/null 2>&1; then echo active-v4; else systemctl is-active igneumd 2>/dev/null; fi; echo '|'; python3 /opt/igneum/bin/wrpc.py call getInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getBlockDagInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getConnectedPeerInfo 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get(\"peerInfo\",[])))' 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getPeerAddresses 2>/dev/null | python3 -c 'import json,sys; j=json.load(sys.stdin); print(len(j.get(\"knownAddresses\",[])), len(j.get(\"bannedAddresses\",[])))' 2>/dev/null; echo '|'; df -h / | awk 'NR==2 { print \$5 }'; echo '|'; free -m | awk 'NR==2 { print \$3 \"/\" \$2 \"MB\" }'" 2>/dev/null) || raw="" + raw=$(sssh "$ip" "export IGNEUM_RPC=ws://127.0.0.1:$RPC_JSON_PORT; if systemctl is-active igneumd-v4 >/dev/null 2>&1; then echo active-v4; else systemctl is-active igneumd 2>/dev/null; fi; echo '|'; python3 /opt/igneum/bin/wrpc.py call getInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getBlockDagInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getConnectedPeerInfo 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get(\"peerInfo\",[])))' 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getPeerAddresses 2>/dev/null | python3 -c 'import json,sys; j=json.load(sys.stdin); print(len(j.get(\"knownAddresses\",[])), len(j.get(\"bannedAddresses\",[])))' 2>/dev/null; echo '|'; df -h / | awk 'NR==2 { print \$5 }'; echo '|'; free -m | awk 'NR==2 { print \$3 \"/\" \$2 \"MB\" }'" 2>/dev/null) || raw="" unit=$(printf '%s' "$raw" | awk -F'|' 'NR==1 { gsub(/\n/, "", $1); print $1 }' | tr -d '\n') info=$(printf '%s' "$raw" | awk 'BEGIN{RS="|"} NR==2' | tr -d '\n') dag=$(printf '%s' "$raw" | awk 'BEGIN{RS="|"} NR==3' | tr -d '\n') diff --git a/infra/seed-nodes/install-rpc-from-mac.sh b/infra/seed-nodes/install-rpc-from-mac.sh new file mode 100755 index 000000000..f90e067e1 --- /dev/null +++ b/infra/seed-nodes/install-rpc-from-mac.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# The public RPC on one seed (the one rpc..igneum.network points at): NET=testnet ./install-rpc-from-mac.sh seed1.testnet [email] +# Adds the igneum--rpc firewall (80, 443 from anywhere) to that server, uploads rpc/ and node/install-rpc.sh, runs it. +. "$(dirname "$0")/lib.sh" +name="${1:-}"; email="${2:-}"; [ -n "$name" ] || die "which seed?" +ip=$(seed_ip "$name"); [ -n "$ip" ] || die "$name not in $SEEDS_TSV" +host="rpc${DNS_ZONE_SUB:+.$DNS_ZONE_SUB}.igneum.network" +hetzner_auth +fw="igneum-$NET-rpc" +if ! hcloud firewall describe "$fw" >/dev/null 2>&1; then + hcloud firewall create --name "$fw" --label igneum=rpc --label net="$NET" >/dev/null + hcloud firewall add-rule "$fw" --direction in --protocol tcp --port 80 --source-ips 0.0.0.0/0 --source-ips ::/0 --description http-acme >/dev/null + hcloud firewall add-rule "$fw" --direction in --protocol tcp --port 443 --source-ips 0.0.0.0/0 --source-ips ::/0 --description https-rpc >/dev/null + log "created firewall $fw (80, 443)" +fi +hcloud firewall apply-to-resource "$fw" --type server --server "$name" >/dev/null 2>&1 || true +log "firewall $fw on $name" +sscp "$HERE/rpc/rpc-filter.py" "$SSH_USER@$ip:/opt/igneum/bin/" +sscp "$HERE/rpc/igneum-rpc-filter.service" "$HERE/rpc/nginx-rpc.conf" "$HERE/node/install-rpc.sh" "$SSH_USER@$ip:/root/" +sssh "$ip" "bash /root/install-rpc.sh '$host' '$email'" +log "public RPC: https://$host (chain id check):" +curl -s -m 15 -X POST -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' "https://$host"; echo diff --git a/infra/seed-nodes/lib.sh b/infra/seed-nodes/lib.sh index 73aa3ac6d..67f490407 100755 --- a/infra/seed-nodes/lib.sh +++ b/infra/seed-nodes/lib.sh @@ -5,8 +5,8 @@ REPO="$(cd "$HERE/../.." && pwd)" export REPO # shellcheck source=config.sh . "$HERE/config.sh" -SEEDS_TXT="$HERE/seeds.txt" -SEEDS_TSV="$HERE/seeds.tsv" +SEEDS_TXT="$HERE/$SEEDS_FILE_BASE.txt" +SEEDS_TSV="$HERE/$SEEDS_FILE_BASE.tsv" BUILD_DIR="$HERE/build" log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; } diff --git a/infra/seed-nodes/node/install-rpc.sh b/infra/seed-nodes/node/install-rpc.sh new file mode 100755 index 000000000..517d926cf --- /dev/null +++ b/infra/seed-nodes/node/install-rpc.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Runs ON the seed as root: install-rpc.sh +# Expects /opt/igneum/bin/rpc-filter.py, /root/igneum-rpc-filter.service, /root/nginx-rpc.conf and /etc/igneum/seed.env +# (EVM_RPC_PORT). Installs the filter unit, the nginx site, then certbot --nginx for the TLS certificate (port 80 and +# 443 must be open: the Mac side adds the igneum-testnet-rpc firewall first). Idempotent. +set -euo pipefail +host="$1"; email="${2:-}" +export DEBIAN_FRONTEND=noninteractive +command -v nginx >/dev/null 2>&1 || { apt-get update -qq; apt-get install -y -qq nginx certbot python3-certbot-nginx >/dev/null; } +chmod +x /opt/igneum/bin/rpc-filter.py +python3 /opt/igneum/bin/rpc-filter.py --test +cp /root/igneum-rpc-filter.service /etc/systemd/system/igneum-rpc-filter.service +systemctl daemon-reload +systemctl enable igneum-rpc-filter >/dev/null 2>&1 +systemctl restart igneum-rpc-filter +sed "s/RPC_HOST/$host/" /root/nginx-rpc.conf > /etc/nginx/sites-available/igneum-rpc +ln -sf /etc/nginx/sites-available/igneum-rpc /etc/nginx/sites-enabled/igneum-rpc +rm -f /etc/nginx/sites-enabled/default +nginx -t +systemctl enable nginx >/dev/null 2>&1; systemctl restart nginx +if [ ! -d "/etc/letsencrypt/live/$host" ]; then + certbot --nginx -n --agree-tos --no-eff-email ${email:+-m "$email"} ${email:---register-unsafely-without-email} -d "$host" --redirect +fi +systemctl is-active igneum-rpc-filter nginx +curl -s -m 10 -X POST -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' http://127.0.0.1:8545; echo +curl -s -m 10 -X POST -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"admin_peers","params":[]}' http://127.0.0.1:8545; echo diff --git a/infra/seed-nodes/node/install-seed.sh b/infra/seed-nodes/node/install-seed.sh index 44b1d318d..6000db62a 100755 --- a/infra/seed-nodes/node/install-seed.sh +++ b/infra/seed-nodes/node/install-seed.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Runs ON the seed VM as root: install-seed.sh "" "" +# Runs ON the seed VM as root: install-seed.sh "" "" [p2p-port rpc-port json-port evm-port] # Expects /opt/igneum/bin/{igneumd,igneum-miner,wrpc.py,run-seed.sh} and /root/igneumd.service. set -euo pipefail -name="$1"; extip="$2"; netargs="$3"; peers="${4:-}" +name="$1"; extip="$2"; netargs="$3"; peers="${4:-}"; p2p="${5:-26611}"; rpc="${6:-26610}"; rpcjson="${7:-28610}"; evm="${8:-26790}" export DEBIAN_FRONTEND=noninteractive command -v chronyd >/dev/null 2>&1 || { apt-get update -qq; apt-get install -y -qq chrony python3 >/dev/null; } systemctl enable --now chrony >/dev/null 2>&1 || true @@ -15,6 +15,11 @@ SEED_NAME=$name EXTERNAL_IP=$extip NETWORK_ARGS=$netargs SEED_PEERS=$peers +P2P_PORT=$p2p +RPC_PORT=$rpc +RPC_JSON_PORT=$rpcjson +EVM_RPC_PORT=$evm +IGNEUM_RPC=ws://127.0.0.1:$rpcjson EXTRA_ARGS= EOF cp /root/igneumd.service /etc/systemd/system/igneumd.service diff --git a/infra/seed-nodes/node/run-seed.sh b/infra/seed-nodes/node/run-seed.sh index a7728592e..e5f51e93d 100755 --- a/infra/seed-nodes/node/run-seed.sh +++ b/infra/seed-nodes/node/run-seed.sh @@ -7,8 +7,12 @@ set -euo pipefail . /etc/igneum/seed.env # shellcheck disable=SC2206 -args=($NETWORK_ARGS --appdir=/var/lib/igneum --rpclisten=127.0.0.1:26610 --rpclisten-json=127.0.0.1:28610 - --listen=0.0.0.0:26611 --externalip="$EXTERNAL_IP" --nodnsseed --disable-upnp --nologfiles --yes +# Ports come from seed.env (5 October 2026: the testnet seeds run on 26810/26811/28810/26890); an env file without +# them is a devnet seed on the 266xx ports. The EVM JSON-RPC binds to loopback too; node/install-rpc.sh puts nginx +# in front of it on the seed that serves rpc..igneum.network. +args=($NETWORK_ARGS --appdir="${APPDIR:-/var/lib/igneum}" --rpclisten=127.0.0.1:"${RPC_PORT:-26610}" --rpclisten-json=127.0.0.1:"${RPC_JSON_PORT:-28610}" + --evm-rpclisten=127.0.0.1:"${EVM_RPC_PORT:-26790}" + --listen=0.0.0.0:"${P2P_PORT:-26611}" --externalip="$EXTERNAL_IP" --nodnsseed --disable-upnp --nologfiles --yes --maxinpeers=128 --outpeers=8 --loglevel=info) IFS=',' read -r -a peers <<< "${SEED_PEERS:-}" for p in "${peers[@]}"; do [ -n "$p" ] && args+=(--addpeer="$p"); done diff --git a/infra/seed-nodes/provision-seed.sh b/infra/seed-nodes/provision-seed.sh index c3b58d8d2..20b4f5917 100755 --- a/infra/seed-nodes/provision-seed.sh +++ b/infra/seed-nodes/provision-seed.sh @@ -14,11 +14,16 @@ CD="$HERE/../cloud-devnet" for try in $(seq 1 20); do sssh "$ip" true >/dev/null 2>&1 && break; log "waiting for ssh ($try)"; sleep 10; done sssh "$ip" true || die "ssh to $ip failed" -if [ "$BUILD_WHERE" = bin ]; then - [ -x "$CD/build/bin/igneumd" ] || die "no $CD/build/bin/igneumd (run ../cloud-devnet/provision.sh build)" - log "uploading prebuilt binaries" +if [ "$BUILD_WHERE" = bin ] || [ "$BUILD_WHERE" = cross ]; then + # bin: ../cloud-devnet/build/bin; cross: infra/cross/out, where infra/cross/build-linux.sh and the PC build job + # (tools/build-job.mjs, Linux target) leave igneumd and igneum-miner; BIN_DIR overrides either + bindir="$CD/build/bin"; [ "$BUILD_WHERE" = cross ] && bindir="$REPO/infra/cross/out"; bindir="${BIN_DIR:-$bindir}" + [ -x "$bindir/igneumd" ] || die "no $bindir/igneumd (run ../cloud-devnet/provision.sh build, infra/cross/build-linux.sh or a Linux build job)" + file "$bindir/igneumd" | grep -q "x86-64" || die "$bindir/igneumd is not an x86-64 binary" + log "uploading prebuilt binaries from $bindir ($(sha256sum "$bindir/igneumd" 2>/dev/null || shasum -a 256 "$bindir/igneumd" | cut -c1-16))" sssh "$ip" 'mkdir -p /opt/igneum/bin' - sscp "$CD/build/bin/igneumd" "$CD/build/bin/igneum-miner" "$SSH_USER@$ip:/opt/igneum/bin/" + sscp "$bindir/igneumd" "$bindir/igneum-miner" "$SSH_USER@$ip:/opt/igneum/bin/" + sssh "$ip" 'chmod +x /opt/igneum/bin/igneumd /opt/igneum/bin/igneum-miner; /opt/igneum/bin/igneumd --version | head -1' || die "the uploaded igneumd does not run on $name" else NODE_SRC="$NODE_SRC" POW_SRC="$POW_SRC" SRC_MODE="$SRC_MODE" "$CD/make-source.sh" cp "$CD/build/src.stamp" "$BUILD_DIR/src.stamp" @@ -34,6 +39,6 @@ peers=$(awk -F'\t' -v n="$name" -v p="$P2P_PORT" '$1 != n { print $4 ":" p }' "$ [ -n "$SEED_PEERS" ] && peers="${peers:+$peers,}$SEED_PEERS" sscp "$CD/node/wrpc.py" "$HERE/node/run-seed.sh" "$SSH_USER@$ip:/opt/igneum/bin/" sscp "$HERE/node/install-seed.sh" "$HERE/node/igneumd.service" "$SSH_USER@$ip:/root/" -sssh "$ip" "bash /root/install-seed.sh '$name' '$ip' '$NETWORK_ARGS' '$peers'" +sssh "$ip" "bash /root/install-seed.sh '$name' '$ip' '$NETWORK_ARGS' '$peers' $P2P_PORT $RPC_PORT $RPC_JSON_PORT $EVM_RPC_PORT" log "started. Health in 30 s:"; sleep 30 "$HERE/health.sh" "$name" || true diff --git a/infra/seed-nodes/rpc/igneum-rpc-filter.service b/infra/seed-nodes/rpc/igneum-rpc-filter.service new file mode 100644 index 000000000..d41ead114 --- /dev/null +++ b/infra/seed-nodes/rpc/igneum-rpc-filter.service @@ -0,0 +1,17 @@ +[Unit] +Description=Igneum public RPC allowlist (between nginx and the node's EVM JSON-RPC) +After=network-online.target igneumd.service + +[Service] +Type=simple +User=igneum +Group=igneum +EnvironmentFile=/etc/igneum/seed.env +Environment=RPC_LISTEN=127.0.0.1:8545 +ExecStart=/bin/sh -c 'exec env RPC_UPSTREAM=http://127.0.0.1:${EVM_RPC_PORT} /usr/bin/python3 /opt/igneum/bin/rpc-filter.py' +Restart=always +RestartSec=5 +MemoryMax=512M + +[Install] +WantedBy=multi-user.target diff --git a/infra/seed-nodes/rpc/nginx-rpc.conf b/infra/seed-nodes/rpc/nginx-rpc.conf new file mode 100644 index 000000000..49640a6d0 --- /dev/null +++ b/infra/seed-nodes/rpc/nginx-rpc.conf @@ -0,0 +1,29 @@ +# The public JSON-RPC: TLS (certbot), rate limited, proxied to the allowlist filter on 127.0.0.1:8545 +# (rpc-filter.py), which forwards to the node's EVM JSON-RPC on loopback. Installed by node/install-rpc.sh as +# /etc/nginx/sites-available/igneum-rpc; certbot adds the TLS block. +limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s; +limit_conn_zone $binary_remote_addr zone=rpcconn:10m; + +server { + listen 80; + listen [::]:80; + server_name RPC_HOST; + + client_max_body_size 256k; + client_body_timeout 10s; + + location / { + limit_req zone=rpc burst=40 nodelay; + limit_conn rpcconn 20; + limit_req_status 429; + limit_conn_status 429; + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + proxy_pass http://127.0.0.1:8545; + proxy_http_version 1.1; + proxy_read_timeout 35s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/infra/seed-nodes/rpc/rpc-filter.py b/infra/seed-nodes/rpc/rpc-filter.py new file mode 100644 index 000000000..e8261ac24 --- /dev/null +++ b/infra/seed-nodes/rpc/rpc-filter.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""The public JSON-RPC allowlist (5 October 2026). Sits between nginx (TLS, rate limit) and the node's Ethereum +JSON-RPC on loopback. Read-mostly: eth_* and igneum_* read methods and eth_sendRawTransaction pass; everything else +is answered with JSON-RPC error -32601 and never reaches the node. Batches are checked element by element. Bodies over +BODY_LIMIT bytes are refused (413). No state, no logging of bodies. + +Environment: RPC_UPSTREAM (default http://127.0.0.1:26890), RPC_LISTEN (default 127.0.0.1:8545). +Self-test: rpc-filter.py --test +""" +import json, os, sys, urllib.request, urllib.error +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = os.environ.get("RPC_UPSTREAM", "http://127.0.0.1:26890") +LISTEN = os.environ.get("RPC_LISTEN", "127.0.0.1:8545") +BODY_LIMIT = 256 * 1024 +ALLOWED = { + # eth: reads + "eth_chainId", "eth_blockNumber", "eth_getBalance", "eth_getCode", "eth_getStorageAt", "eth_getTransactionCount", + "eth_getBlockByNumber", "eth_getBlockByHash", "eth_getBlockReceipts", "eth_getBlockTransactionCountByNumber", + "eth_getTransactionByHash", "eth_getTransactionByBlockNumberAndIndex", "eth_getTransactionReceipt", "eth_getLogs", + "eth_call", "eth_estimateGas", "eth_gasPrice", "eth_maxPriorityFeePerGas", "eth_feeHistory", "eth_syncing", + "eth_protocolVersion", "eth_mining", "eth_accounts", + # the one write + "eth_sendRawTransaction", + # igneum: reads (igneum_submitProofRecord and igneum_exportSegments stay out: a public endpoint never takes a record or exports) + "igneum_estimateGas", "igneum_getBudgets", "igneum_getProofRecords", "igneum_getProvingStatus", "igneum_getSegment", + "igneum_getShardPlan", "igneum_getAssignedShards", "igneum_getTransactionStatus", + # identity + "net_version", "net_listening", "net_peerCount", "web3_clientVersion", +} + +def err(id_, code, msg): + return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": msg}} + +def check(req): + """None when the request may pass, else the error reply.""" + if not isinstance(req, dict): + return err(None, -32600, "invalid request") + m = req.get("method") + if not isinstance(m, str) or m not in ALLOWED: + return err(req.get("id"), -32601, "method not available on the public RPC") + return None + +def filter_body(raw): + """(forward: bool, reply bytes or None, upstream body bytes or None)""" + try: + body = json.loads(raw) + except Exception: + return False, json.dumps(err(None, -32700, "parse error")).encode(), None + if isinstance(body, list): + if not body or len(body) > 50: + return False, json.dumps(err(None, -32600, "batch of 1 to 50 requests")).encode(), None + bad = [check(r) for r in body] + if all(b is None for b in bad): + return True, None, raw + # a batch with a refused element is answered in full here, nothing reaches the node + return False, json.dumps([b if b is not None else err(r.get("id"), -32601, "refused with the batch") for r, b in zip(body, bad)]).encode(), None + e = check(body) + if e is not None: + return False, json.dumps(e).encode(), None + return True, None, raw + +class H(BaseHTTPRequestHandler): + server_version = "igneum-rpc-filter/1" + protocol_version = "HTTP/1.1" + def log_message(self, *a): # nginx logs the request line; nothing here + pass + def _send(self, code, body, ctype="application/json"): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_GET(self): + self._send(200, b'{"service":"igneum public rpc","methods":"eth_* reads, igneum_* reads, eth_sendRawTransaction"}') + def do_OPTIONS(self): + self._send(204, b"") + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + if n > BODY_LIMIT: + return self._send(413, json.dumps(err(None, -32600, "body over 256 KiB")).encode()) + raw = self.rfile.read(n) + forward, reply, up = filter_body(raw) + if not forward: + return self._send(200, reply) + try: + r = urllib.request.urlopen(urllib.request.Request(UPSTREAM, data=up, headers={"Content-Type": "application/json"}), timeout=30) + self._send(r.status, r.read()) + except urllib.error.HTTPError as e: + self._send(e.code, e.read()) + except Exception: + self._send(502, json.dumps(err(None, -32000, "node unavailable")).encode()) + +def selftest(): + ok = lambda b: filter_body(json.dumps(b).encode())[0] + assert ok({"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []}) + assert ok({"jsonrpc": "2.0", "id": 1, "method": "eth_sendRawTransaction", "params": ["0x00"]}) + assert ok({"jsonrpc": "2.0", "id": 1, "method": "igneum_getBudgets", "params": []}) + assert not ok({"jsonrpc": "2.0", "id": 1, "method": "igneum_submitProofRecord", "params": []}) + assert not ok({"jsonrpc": "2.0", "id": 1, "method": "igneum_exportSegments", "params": []}) + assert not ok({"jsonrpc": "2.0", "id": 1, "method": "admin_peers"}) + assert not ok({"jsonrpc": "2.0", "id": 1, "method": "debug_traceTransaction"}) + assert not ok({"jsonrpc": "2.0", "id": 1}) + assert not ok([]) + assert ok([{"jsonrpc": "2.0", "id": 1, "method": "eth_chainId"}, {"jsonrpc": "2.0", "id": 2, "method": "eth_blockNumber"}]) + f, reply, _ = filter_body(json.dumps([{"jsonrpc": "2.0", "id": 1, "method": "eth_chainId"}, {"jsonrpc": "2.0", "id": 2, "method": "admin_x"}]).encode()) + assert not f and len(json.loads(reply)) == 2 and json.loads(reply)[1]["error"]["code"] == -32601 + f, reply, _ = filter_body(b"not json") + assert not f and json.loads(reply)["error"]["code"] == -32700 + print("rpc-filter: self-test ok (%d methods allowed)" % len(ALLOWED)) + +if __name__ == "__main__": + if "--test" in sys.argv: + selftest(); sys.exit(0) + host, port = LISTEN.rsplit(":", 1) + ThreadingHTTPServer((host, int(port)), H).serve_forever() diff --git a/infra/seed-nodes/seeds-testnet.tsv b/infra/seed-nodes/seeds-testnet.tsv new file mode 100644 index 000000000..d0e6e59ca --- /dev/null +++ b/infra/seed-nodes/seeds-testnet.tsv @@ -0,0 +1,3 @@ +seed1.testnet hetzner nbg1 195.201.35.33 cx23 2026-10-05T15:38:38Z +seed2.testnet hetzner ash 5.161.232.205 cpx21 2026-10-05T15:39:24Z +seed3.testnet hetzner sin 5.223.52.210 cpx22 2026-10-05T15:40:16Z diff --git a/infra/seed-nodes/seeds-testnet.txt b/infra/seed-nodes/seeds-testnet.txt new file mode 100644 index 000000000..9117fd10b --- /dev/null +++ b/infra/seed-nodes/seeds-testnet.txt @@ -0,0 +1,3 @@ +195.201.35.33:26811 +5.161.232.205:26811 +5.223.52.210:26811 diff --git a/tools/build-job.mjs b/tools/build-job.mjs index 32ce14c04..1995994d9 100755 --- a/tools/build-job.mjs +++ b/tools/build-job.mjs @@ -5,6 +5,7 @@ // and puts them where the packaging scripts look. // node tools/build-job.mjs run [--node vendor/igneum-node-v4] [--target ae432dc7] [--budget-minutes 40] // [--stage-minutes '{"linux":20}'] [--targets linux,windows] [--no-tests] +// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app] // [--title "..."] [--no-place] [--out dir] pack + publish + watch + fetch // node tools/build-job.mjs publish [the same flags] pack + publish, prints the id // node tools/build-job.mjs watch STAGE and RESULT lines as they land @@ -31,7 +32,7 @@ const RELAY_BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace( const argv = process.argv.slice(2); const flags = {}; const pos = []; -const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'help']); +const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'help']); for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (!BOOL.has(k) && next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; } @@ -198,6 +199,9 @@ function placeFor(o) { function publish() { const pack = ['packaging/windows/push-build-inputs.sh']; if (flags.node) pack.push('--node', flags.node); + if (flags['node-tests']) pack.push('--node-tests', String(flags['node-tests'])); + if (flags['app-tests']) pack.push('--app-tests', String(flags['app-tests'])); + if (flags['no-app']) pack.push('--no-app'); if (flags['no-deploy']) pack.push('--no-deploy'); console.log(`$ ${pack.join(' ')}`); const r = spawnSync('bash', pack, { cwd: ROOT, stdio: 'inherit' }); From 1cfbbd1ceb5879575385fc63d0fecc989d3de4e6 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:58:18 +0000 Subject: [PATCH 2/5] Testnet seeds: debian-13 images (the PC build's glibc 2.39), a glibc check before the upload, the final genesis in docs/testnet/README.md Co-Authored-By: Claude Fable 5.1 --- docs/testnet/README.md | 25 +++++++++++++------------ infra/seed-nodes/config.sh | 5 ++++- infra/seed-nodes/provision-seed.sh | 8 ++++++++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/testnet/README.md b/docs/testnet/README.md index a2fd00d31..3b66f41a1 100644 --- a/docs/testnet/README.md +++ b/docs/testnet/README.md @@ -7,7 +7,7 @@ branch `testnet-params` (worktree `vendor/igneum-node-testnet`, forked from `fin merged on 5 October 2026 into the fork's `release-0.3.6` (worktree `vendor/igneum-node-036`), with one change the sign-off added: the devnet and the simnet keep the prototype fee set until a height switch (`fees_v1_activation_daa`) or a `fees` object in the override file moves them; the testnet and the mainnet carry -calibrated v1 from genesis (section 3). Nothing is deployed; the testnet has no seed nodes yet (section 5). +calibrated v1 from genesis (section 3). The three seed nodes went up on 5 October 2026 from the final genesis below (`docs/plans/testnet-go.md`); nothing mines until the owner's go. ## 1. Identity @@ -21,13 +21,14 @@ calibrated v1 from genesis (section 3). Nothing is deployed; the testnet has no | P2P port | 26611 | 26811 | `network.rs`, `default_p2p_port` (a later suffix takes the next port) | | wRPC Borsh, JSON | 27610, 28610 | 27810, 28810 | `network.rs` | | EVM JSON-RPC port | 26790 | 26890 | `igneum/exec/src/config.rs`, `default_evm_rpc_port` | -| DNS seeders | none | none (the list is filled when the seed nodes exist, `docs/plans/seed-nodes.md`) | `TESTNET_PARAMS.dns_seeders` | +| DNS seeders | none | `seed1.testnet.igneum.network`, `seed2.testnet.igneum.network`, `seed3.testnet.igneum.network` (A records on deSEC, `infra/seed-nodes/dns.sh`; Nuremberg, Ashburn, Singapore) | `TESTNET_PARAMS.dns_seeders` | | Override file (`--override-params-file`) | allowed | refused, as on mainnet | `kaspad/src/daemon.rs` | | `IGNEUM_POW_*` environment | ignored by the node from this branch (G12: the params' schedule is installed on every start) | ignored | `kaspad/src/daemon.rs` | -Start a node on it: `igneumd --testnet --netsuffix 1` (the flag `--testnet` is "Use the Igneum test network"; -`--netsuffix` defaults to 10 in `kaspad/src/args.rs` and must be set to 1 until the default is changed, which is -one line and waits for the sign-off). +Start a node on it: `igneumd --testnet` (the flag `--testnet` is "Use the Igneum test network"; `--netsuffix` +defaults to 1 in `kaspad/src/args.rs` since 5 October 2026, so `--netsuffix=1` is implied). The public JSON-RPC is +`https://rpc.testnet.igneum.network` (seed1, nginx with TLS and a rate limit, an allowlist of `eth_*` and `igneum_*` +read methods plus `eth_sendRawTransaction`; `infra/seed-nodes/rpc/`). ## 2. Genesis @@ -37,9 +38,9 @@ one line and waits for the sign-off). | Bits | `0x1d100000` (2^28 expected hashes per block) | the devnet's launch difficulty, sized for a few hundred MH/s; the DAA takes over after 150 samples (600 blocks); re-size to the announced launch fleet | | Nonce, DAA score | 0, 0 | | | UTXO commitment | empty | | -| Coinbase payload message | `igneum-testnet-1 \| 2026-10-05 \| proposed, not final \| coins here have no value \| resets are announced` | after the OP-FALSE script, as the devnet's `igneum-devnet`. The words "proposed, not final" are the proposal's and are part of the hashed genesis; the sign-off adopted the genesis as computed. Changing the message is one `print_genesis_hashes` run and a new hash, and must happen before the first public node starts, never after (`docs/plans/release-0.3.6.md`, section 6) | -| Hash | `52a3e6a9ddd79d603ff9e3a27487fb5d0ca5ca6f633fe20ca727e1faa355fc7e` | computed 4 October 2026 by `print_genesis_hashes` on the branch (pinned by `test_genesis_hashes`, re-run green on `release-0.3.6` on 5 October 2026); changes with any field above | -| Merkle root | `3060aab78494de3cbda68619c0132300aa486dc3153dbb78b50a430f053b3a84` | same | +| Coinbase payload message | `igneum-testnet-1 \| 2026-10-05 \| coins here have no value \| resets are announced` | after the OP-FALSE script, as the devnet's `igneum-devnet`. FINAL 5 October 2026 (fork branch `testnet-infra`, 1c19441d): the proposal's "proposed, not final" was dropped before the first public node started, as `docs/plans/release-0.3.6.md` section 6 required. The message never changes again on `igneum-testnet-1` | +| Hash | `87617621714af1bf33bd17f291f90a7e0bff760a669bba53083ea8c0f7cbd840` | computed 5 October 2026 by `print_genesis_hashes` (two runs: the merkle root first, then the header hash over it), pinned by `test_genesis_hashes` and `igneum_testnet_identity`; the three seeds started from it at height 0 the same day. The proposal's hash `52a3e6a9...` is void | +| Merkle root | `44acfc40b1c6647011510f3c39ddb7606f979df92ad26a2914de56e44610efad` | same | ## 3. Consensus parameters @@ -74,10 +75,10 @@ Everything else (mass limits, GHOSTDAG k, merge depth, pruning) is the devnet's | Item | State | |---|---| | Sign-off of every value above | done: adopted by the owner on 5 October 2026, as proposed | -| `--netsuffix` default 1 under `--testnet` | one line in `kaspad/src/args.rs`, still to do (not in the 0.3.6 merge; `--netsuffix 1` must be passed until then) | -| Seed nodes and the DNS seeder list | `docs/plans/seed-nodes.md`; the list in `TESTNET_PARAMS` is empty on purpose | -| The public RPC and explorer | `site/wallet.html` carries a placeholder RPC URL until they exist | +| `--netsuffix` default 1 under `--testnet` | done 5 October 2026 (fork `testnet-infra` 1c19441d) | +| Seed nodes and the DNS seeder list | done 5 October 2026: three Hetzner seeds (`infra/seed-nodes/seeds-testnet.tsv`), the three names in `TESTNET_PARAMS.dns_seeders`; `docs/plans/testnet-go.md` | +| The public RPC and explorer | the RPC is `https://rpc.testnet.igneum.network` (5 October 2026); the explorer is not built | | The prover's table mirror and fixtures | `docs/analysis/base-fee-floor.md` section 4 | -| The app's testnet build | the app's packaged config names the network; `igneum-testnet-1` needs the network and ports there (`app/igneum-app/src/config.rs`, `Runtime.network`) | +| The app's testnet build | branch `testnet-app` (5 October 2026): the packaged file's `network`, `peers`, `public_rpc`; the testnet's ports, seeds and node directory in `app/igneum-app/src/config.rs`; the release engineer cuts 0.4.0 from it at go | | The params digest in the handshake (X18) | separate work, before the testnet | | Terms on the download page | `site/index.html#testnet-terms`, on branch `testnet-prep` with this file | diff --git a/infra/seed-nodes/config.sh b/infra/seed-nodes/config.sh index e43afae10..c6774ad64 100755 --- a/infra/seed-nodes/config.sh +++ b/infra/seed-nodes/config.sh @@ -11,7 +11,10 @@ SEED_TYPE="${SEED_TYPE:-cx23}" # 2 shared Intel vCPU, 4 GB, 40 GB # node keeps up to four 256 MiB lottery caches (M15 cap) beside rocksdb, and the on-VM # build wants 4 GB plus swap. US seeds: cpx11 (2 GB) or cpx21; Singapore: cpx22 (30.99). SEED_LOCATION="${SEED_LOCATION:-fsn1}" # fsn1 Falkenstein, nbg1 Nuremberg, hel1, ash, hil, sin -IMAGE="${IMAGE:-debian-12}" +IMAGE="${IMAGE:-debian-13}" # debian-13 (glibc 2.41) since 5 October 2026: the PC build job's Linux binaries come from + # WSL2 Ubuntu 24.04 and want glibc 2.38/2.39, which debian-12 (2.36) refuses ("GLIBC_2.38 + # not found" on the first testnet provision). The devnet seed stays on debian-12 with the + # Mac's zig cross-build (glibc 2.36). Rebuild: hcloud server rebuild --image debian-13. DO_SIZE="${DO_SIZE:-s-2vcpu-4gb}" DO_REGION="${DO_REGION:-fra1}" DO_IMAGE="${DO_IMAGE:-debian-12-x64}" diff --git a/infra/seed-nodes/provision-seed.sh b/infra/seed-nodes/provision-seed.sh index 20b4f5917..fc3881268 100755 --- a/infra/seed-nodes/provision-seed.sh +++ b/infra/seed-nodes/provision-seed.sh @@ -20,6 +20,14 @@ if [ "$BUILD_WHERE" = bin ] || [ "$BUILD_WHERE" = cross ]; then bindir="$CD/build/bin"; [ "$BUILD_WHERE" = cross ] && bindir="$REPO/infra/cross/out"; bindir="${BIN_DIR:-$bindir}" [ -x "$bindir/igneumd" ] || die "no $bindir/igneumd (run ../cloud-devnet/provision.sh build, infra/cross/build-linux.sh or a Linux build job)" file "$bindir/igneumd" | grep -q "x86-64" || die "$bindir/igneumd is not an x86-64 binary" + # the glibc the binary wants against the one the seed has (the class of the 5 October 2026 failure: a PC-built binary + # on debian-12); both printed, the upload refused when the binary asks for more than the seed can give + want=$(strings -a "$bindir/igneumd" 2>/dev/null | grep -o 'GLIBC_2\.[0-9]*' | sort -t. -k2 -n | tail -1 | cut -d_ -f2) + have=$(sssh "$ip" 'ldd --version 2>/dev/null | head -1 | grep -o "[0-9]*\.[0-9]*$"' || echo 0) + log "glibc: binary wants ${want:-?}, $name has ${have:-?}" + if [ -n "$want" ] && [ -n "$have" ] && [ "$(printf '%s\n%s\n' "$want" "$have" | sort -t. -k2 -n | tail -1)" != "$have" ]; then + die "$bindir/igneumd needs glibc $want, $name has $have (rebuild the seed on debian-13, or cross-build with infra/cross/build-linux.sh)" + fi log "uploading prebuilt binaries from $bindir ($(sha256sum "$bindir/igneumd" 2>/dev/null || shasum -a 256 "$bindir/igneumd" | cut -c1-16))" sssh "$ip" 'mkdir -p /opt/igneum/bin' sscp "$bindir/igneumd" "$bindir/igneum-miner" "$SSH_USER@$ip:/opt/igneum/bin/" From 09c646ddce9c12da62cb026eb428aedef34db4ef Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:59:53 +0000 Subject: [PATCH 3/5] Seed env values quoted (the launcher sources the file; --netsuffix=1 ran as a command on the first testnet start) Co-Authored-By: Claude Fable 5.1 --- infra/seed-nodes/node/install-seed.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/infra/seed-nodes/node/install-seed.sh b/infra/seed-nodes/node/install-seed.sh index 6000db62a..ff9e6e899 100755 --- a/infra/seed-nodes/node/install-seed.sh +++ b/infra/seed-nodes/node/install-seed.sh @@ -10,11 +10,13 @@ id igneum >/dev/null 2>&1 || useradd --system --home /var/lib/igneum --shell /us mkdir -p /var/lib/igneum /etc/igneum /opt/igneum/bin chmod +x /opt/igneum/bin/* chown -R igneum:igneum /var/lib/igneum +# values quoted: the launcher sources this file with `.`, so an unquoted "--testnet --netsuffix=1" ran "--netsuffix=1" +# as a command (5 October 2026, the first testnet seed); systemd's EnvironmentFile reads the quotes too cat > /etc/igneum/seed.env < Date: Mon, 5 Oct 2026 16:02:47 +0000 Subject: [PATCH 4/5] build-job.mjs: the watcher reads the SUMMARY wherever it sits in the report (the closing report starts with the app header; two finished builds showed as still running on 5 October 2026) Co-Authored-By: Claude Fable 5.1 --- tools/build-job.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tools/build-job.mjs b/tools/build-job.mjs index 1995994d9..ebe71a67e 100755 --- a/tools/build-job.mjs +++ b/tools/build-job.mjs @@ -54,13 +54,24 @@ function db() { return j.rows; }; } -const summaryOf = lines => { const l = (lines || '').split('\n')[0]; if (!l.startsWith('SUMMARY ')) return null; try { return JSON.parse(l.slice(8)); } catch { return null; } }; +// the closing SUMMARY line wherever it sits in the report (5 October 2026: it was read from line 0 only, so a report whose +// first line is a STAGE or RESULT line hid a finished job and the watcher said "still running" past two done builds) +const summaryOf = lines => { for (const l of (lines || '').split('\n')) { if (!l.startsWith('SUMMARY ')) continue; try { return JSON.parse(l.slice(8)); } catch { return null; } } return null; }; const FINAL = new Set(['done', 'failed', 'timeout', 'aborted']); /// The newest report per machine for the job: { machine, summary, lines[] }. async function reports(sql, id) { - const rows = await sql(`SELECT DISTINCT ON (run_id) run_id, machine, received_at, lines FROM miner_logs WHERE run_id LIKE $1 AND label LIKE 'job-%' ORDER BY run_id, received_at DESC`, [`job-${id}-%`]); - return rows.map(r => ({ run_id: r.run_id, machine: r.machine, received_at: r.received_at, summary: summaryOf(r.lines), lines: (r.lines || '').split('\n') })); + // every report of the job, newest first; per run the newest row that carries a SUMMARY wins (a progress upload can + // land after the closing report), else the newest row + const rows = await sql(`SELECT run_id, machine, received_at, lines FROM miner_logs WHERE run_id LIKE $1 AND label LIKE 'job-%' ORDER BY run_id, received_at DESC`, [`job-${id}-%`]); + const byRun = new Map(); + for (const r of rows) { + const cur = byRun.get(r.run_id); + const sum = summaryOf(r.lines); + if (!cur) { byRun.set(r.run_id, { ...r, summary: sum }); continue; } + if (!cur.summary && sum) byRun.set(r.run_id, { ...r, summary: sum }); + } + return [...byRun.values()].map(r => ({ run_id: r.run_id, machine: r.machine, received_at: r.received_at, summary: r.summary, lines: (r.lines || '').split('\n') })); } async function watch(id, quiet = false) { From ff1e50e69e3973faec4a8f41c0ff17899a9a8a74 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:04:09 +0000 Subject: [PATCH 5/5] docs/plans/testnet-go.md: the go checklist with the state of every line at 16:05 UTC, the final genesis, the cost Co-Authored-By: Claude Fable 5.1 --- docs/plans/testnet-go.md | 117 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/plans/testnet-go.md diff --git a/docs/plans/testnet-go.md b/docs/plans/testnet-go.md new file mode 100644 index 000000000..7a70c5dd5 --- /dev/null +++ b/docs/plans/testnet-go.md @@ -0,0 +1,117 @@ +# Igneum public testnet: the go checklist + +Prepared 5 October 2026 by the infrastructure and consensus engineer for the 19:00 BST (18:00 UTC) opening. State of +every line as of 16:05 UTC. Nothing mines until the owner says go; the seeds hold the chain at height 0. + +## What runs now + +| Piece | Where | State at 16:05 UTC | +|---|---|---| +| `igneum-testnet-1` seed 1 | `seed1.testnet.igneum.network` = 195.201.35.33, Hetzner cx23, Nuremberg (nbg1), Debian 13 | up, unit `igneumd` active, p2p 26811 open, 2 peers, height 0, sink = genesis | +| seed 2 | `seed2.testnet.igneum.network` = 5.161.232.205, Hetzner cpx21, Ashburn (ash), Debian 13 | up, 2 peers, height 0 | +| seed 3 | `seed3.testnet.igneum.network` = 5.223.52.210, Hetzner cpx22, Singapore (sin), Debian 13 | up, 2 peers, height 0 | +| Public JSON-RPC | `https://rpc.testnet.igneum.network` (seed 1: nginx, Let's Encrypt, 20 req/s per address with a burst of 40, 20 connections per address, bodies to 256 KiB, then `rpc-filter.py` on 127.0.0.1:8545, then the node's EVM RPC on 127.0.0.1:26890) | live: `eth_chainId` = 0x116e (4462), `eth_blockNumber` = 0x0, `net_version` = 4462; `admin_peers` and `igneum_submitProofRecord` refused with -32601 | +| DNS | deSEC (`ns1.desec.io`, `ns2.desec.org`), A records for the three seeds and `rpc.testnet`, TTL 3600 | all four resolve from the authoritative servers | +| Firewalls | `igneum-testnet-seed` (22, 26811, icmp) on all three; `igneum-testnet-rpc` (80, 443) on seed 1 only | applied | +| The node binary | `igneumd 1c19441d` (fork branch `testnet-infra`), built on PC 1 by build job `build-20261005-154330` (WSL2 Ubuntu 24.04, glibc 2.39), sha256 `a9ea25f8ada29e3ee1dfc2ccced4e088a56237511be75d5d80f7bb7a72414b10` | on every seed as `/opt/igneum/bin/igneumd` | +| Mining | none | nothing mines; no `--enable-unsynced-mining`, no miner process anywhere on the testnet | + +Each seed's start-up log, identical on the three: + +``` +Fees on igneum-testnet-1: pgas table v1, B_p 120000 pgas, S_p 30000 pgas, floors 100000000000 wei per gas and 10000000000000 wei per pgas; calibrated v1 from DAA score 0 +Consensus params digest: b7d8c915f20f5af261e69e7f4aa9c3d03a9c4a462174776502b63fa1e9ec8447 (exchanged in the p2p handshake; a peer with another digest is refused) +igneumd/2.1.0 +[igneum-exec] genesis 87617621714af1bf33bd17f291f90a7e0bff760a669bba53083ea8c0f7cbd840 executed: chain id 4462, registry at 0x0000000000000000000000000000000000000210 +``` + +`health.sh` shows `synced=False` on all three: that is the no-blocks state (a node is synced once it has blocks past +genesis), not a fault. The check: `cd infra/seed-nodes && NET=testnet ./health.sh`. + +## The genesis, final + +| Field | Value | +|---|---| +| Network | `igneum-testnet-1`, chain id 4462, address prefix `igneumtest`, ports 26810 (gRPC), 26811 (p2p), 28810 (wRPC JSON), 26890 (EVM JSON-RPC) | +| Coinbase message | `igneum-testnet-1 \| 2026-10-05 \| coins here have no value \| resets are announced` ("proposed, not final" dropped before the first public node) | +| Timestamp | 1,791,158,400,000 ms = 2026-10-05T00:00:00Z | +| Bits | `0x1d100000` | +| Hash | `87617621714af1bf33bd17f291f90a7e0bff760a669bba53083ea8c0f7cbd840` | +| Merkle root | `44acfc40b1c6647011510f3c39ddb7606f979df92ad26a2914de56e44610efad` | +| Consensus digest | `b7d8c915f20f5af261e69e7f4aa9c3d03a9c4a462174776502b63fa1e9ec8447` | +| Fees | `CALIBRATED_V1` from DAA 0 | +| DNS seeders in `TESTNET_PARAMS` | `seed1.testnet.igneum.network`, `seed2.testnet.igneum.network`, `seed3.testnet.igneum.network` | +| `--netsuffix` | defaults to 1 under `--testnet` | + +The proposal's hash `52a3e6a9...` is void: it hashed the old message. Any node built from a fork commit before 1c19441d +has the old genesis and never completes a handshake with the seeds (different genesis, different digest). + +## Branches and commits (nothing pushed, nothing merged) + +| Repository | Branch | Head | What | +|---|---|---|---| +| node fork (`vendor/igneum-node-testnet-infra`, from `release-0.3.6` 2b6d23ef) | `testnet-infra` | 1c19441d | final genesis message, hash and merkle root; `--netsuffix` default 1; the three DNS seeders; `igneum_testnet_identity` and `test_genesis_hashes` updated | +| app repository (`igneum-wt-testnet-infra`, from master 23d11d5) | `testnet-infra` | 358e565 | `infra/seed-nodes`: `NET=testnet` profile, `seeds-testnet.tsv`, `dns.sh`, `rpc/` (filter, unit, nginx site), `node/install-rpc.sh`, `install-rpc-from-mac.sh`, ports from `seed.env`, glibc check, debian-13 images, quoted env values; `tools/build-job.mjs` forwards `--node-tests`/`--app-tests` and its watcher reads the SUMMARY wherever it sits; `docs/testnet/README.md` final genesis; this file | +| app repository (`igneum-wt-testnet-app`, from `testnet-infra` 9fa2bb3) | `testnet-app` | c00df85 | the app's network setting: `Packaged.network/peers/public_rpc`, `Network` enum with the testnet's ports, seeds and node directory, `Runtime::from_env_and_packaged`, the dashboard's chain label `testnet-1` with the public RPC on its tooltip, the testnet node keeps its DNS seeders; `packaged-config.sh` `IGNEUM_PACKAGE_NETWORK` (default testnet; the fleet's devnet builds pass `devnet`) | +| app repository (`igneum-wt-testnet-wallet`, from `wallet-v1` a238781) | `testnet-wallet` | fe6e5e8 | `igneum-common`: `Packaged.network`, `TESTNET_PUBLIC_RPC`, `chain_id`, `effective_public_rpc`; the wallet engine reads the effective URL; the wallet's packaged config follows `IGNEUM_PACKAGE_NETWORK` | + +Tests: `kaspa-consensus-core` and `igneum-app` suites on PC 2, build job `build-20261005-155547`, both exit 0 +(cargo's own status; the relayed log keeps only the last `test result` line). `cargo test -p igneum-common` on the +Mac: 27 passed (the crate is not in the PC build inputs). The packager's self-test: all checks passed on both branches. +`cargo check --tests` of the app on the Mac: clean. + +## The go: what the project lead presses, in order + +| # | Step | Who presses | State at 16:05 UTC | +|---|---|---|---| +| 1 | Seeds up at height 0, peered, the public RPC answering | nobody (done) | DONE: three seeds, 2 peers each, RPC live | +| 2 | Merge `testnet-infra` and `testnet-app` to master; the fork's `testnet-infra` into `release-0.3.6` (or the release branch the 0.4.0 cut uses) | the release engineer, on the project lead's word | NOT DONE: branches ready, nothing merged | +| 3 | Cut 0.4.0 from `testnet-app` (`tools/ship-app.mjs 0.4.0 --node vendor/igneum-node-testnet-infra ...`); the Mac DMG, the Windows installer through the PC build job; the packaged file must say `"network": "testnet"` (the default) | the release engineer | NOT DONE: the packager writes the testnet by default; the fleet's devnet update, if any, needs `IGNEUM_PACKAGE_NETWORK=devnet` | +| 4 | The prover's fee-table mirror at `CALIBRATED_V1` (`proving/igneum-prove/core/src/config.rs`, `pgas.rs`; `docs/plans/release-0.3.6.md` section 5 step 6): on the testnet fees are v1 from genesis, so a prover with the prototype table produces shard statements the node refuses. Needed before the first testnet proof, not before the first block | the execution engineer | NOT DONE | +| 5 | History rewrite (`docs/plans/history-rewrite.md`, G14: the 40 commits with a personal name) | the project lead, then the repository goes public | NOT DONE | +| 6 | Repository public (`gh repo edit igneum-network/igneum --visibility public`) | the project lead | NOT DONE | +| 7 | Downloads public: the 0.4.0 manifest in the downloads folder, `publish-manifest.sh --deploy` | the release engineer | NOT DONE | +| 8 | The site's buttons: the download section points at 0.4.0, `site/wallet.html` carries `https://rpc.testnet.igneum.network` and chain id 4462 in place of the placeholder, the terms card (`#testnet-terms`) stays; `node site/build.mjs`, push to master (Vercel deploys) | the site agent | NOT DONE: the placeholder is still in `site/wallet.html` | +| 9 | Announcement text | the project lead | PLACEHOLDER: "Igneum testnet-1 is open. Coins here have no value. Resets are announced seven days ahead. Download: igneum.network. RPC: rpc.testnet.igneum.network, chain id 4462." (the project lead's words replace this) | +| 10 | The first miner: one app on the testnet (PC 1 or PC 2 with the 0.4.0 build, or `igneumd --testnet` plus `igneum-miner --network testnet` by hand) produces block 1; the seeds relay it, `health.sh` shows blocks=1 on all three, `synced=True` | the project lead says go, the miner-community lead starts it | NOT DONE: nothing mines until the word | +| 11 | Watch: `NET=testnet ./health.sh --watch`, the RPC's `eth_blockNumber`, the DAA after 600 blocks (the launch difficulty `0x1d100000` is sized for a few hundred MH/s) | the infrastructure engineer | ready | + +Legal is out of scope here (the project lead: "all but legal"). + +## Cost + +| Item | USD per month, net (Hetzner API, 5 October 2026) | +|---|---| +| seed 1, cx23 nbg1 | 6.49 | +| seed 2, cpx21 ash (4 GB; the only 4 GB type in the US at this price) | 37.49 | +| seed 3, cpx22 sin | 30.99 | +| three primary IPv4 | 1.80 | +| total | 76.77 net, about 92 gross; billed hourly, 20 TB traffic included per server | + +A cheaper US seed is cpx11 (2 GB, 20.49), rejected because the node keeps up to four 256 MiB lottery caches once +the chain has epochs; the unit's `MemoryMax=3200M` would not fit. The devnet seed (`igneum-seed-1`, 6.49) is untouched. + +## What was not done, and what to watch + +| Item | Note | +|---|---| +| The explorer | not built; the public RPC serves the wallet and MetaMask | +| `site/wallet.html` RPC placeholder | still a placeholder; step 8 | +| The Windows WSL verifier wrapper, the prover's table mirror | `docs/plans/release-0.3.6.md` section 7; step 4 above | +| The app's Windows side | `testnet-app` compiles on the Mac and its suite passed on PC 2 (Linux); the Windows build is the 0.4.0 cut's job | +| Seeds and proofs | the seeds run no proof verifier (`verifier: Off`); they relay and hold the chain. A seed never includes proof records, which is fine for a seed | +| Build inputs zip | `build-inputs.zip` on the downloads host is ONE file for every agent: a second agent's push between a job's publish and its fetch fails that job's sha256 check. Both testnet jobs fetched the right zip (verified by sha256 before each fetch); the hazard stays until the zip carries the job id in its name | +| glibc | a PC-built Linux binary wants glibc 2.39 (Ubuntu 24.04); the seeds are Debian 13 (2.41) for that reason, and `provision-seed.sh` now refuses a binary the seed cannot run. The Mac's zig cross-build (glibc 2.36) stays the route for a Debian 12 host | + +## How to redo any part + +``` +cd infra/seed-nodes +NET=testnet ./health.sh # one line per seed +NET=testnet ./dns.sh --check # the four A records +NET=testnet BUILD_WHERE=cross ./provision-seed.sh seed2.testnet # re-install the node from infra/cross/out (a new binary) +NET=testnet ./install-rpc-from-mac.sh seed1.testnet # the public RPC again (idempotent; certbot keeps its certificate) +``` + +The node binary for the seeds: `node tools/build-job.mjs run --node /Users/joshm/Projects/igneum/vendor/igneum-node-testnet-infra --target ae432dc7 --targets linux --no-tests` +then `node tools/build-job.mjs fetch ` (lands in `infra/cross/out/`).