Merge testnet-infra: testnet seed profile, DNS and RPC installers, build-job watcher fix, docs/testnet, the go checklist

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 16:05:16 +00:00
commit a0a75d345a
18 changed files with 469 additions and 45 deletions

117
docs/plans/testnet-go.md Normal file
View file

@ -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 <id>` (lands in `infra/cross/out/`).

View file

@ -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 |

View file

@ -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 <name> --image debian-13.
DO_SIZE="${DO_SIZE:-s-2vcpu-4gb}"
DO_REGION="${DO_REGION:-fra1}"
DO_IMAGE="${DO_IMAGE:-debian-12-x64}"
@ -25,11 +28,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}"

View file

@ -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")

37
infra/seed-nodes/dns.sh Executable file
View file

@ -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 <subname> <ip>; 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"

View file

@ -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')

View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# The public RPC on one seed (the one rpc.<net>.igneum.network points at): NET=testnet ./install-rpc-from-mac.sh seed1.testnet [email]
# Adds the igneum-<net>-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

View file

@ -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)" "$*"; }

View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Runs ON the seed as root: install-rpc.sh <rpc host, e.g. rpc.testnet.igneum.network> <contact email>
# 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

View file

@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Runs ON the seed VM as root: install-seed.sh <name> <external-ip> "<network args>" "<peer list>"
# Runs ON the seed VM as root: install-seed.sh <name> <external-ip> "<network args>" "<peer list>" [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
@ -10,11 +10,18 @@ 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 <<EOF
SEED_NAME=$name
EXTERNAL_IP=$extip
NETWORK_ARGS=$netargs
SEED_PEERS=$peers
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

View file

@ -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.<net>.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

View file

@ -14,11 +14,24 @@ 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"
# 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 "$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 +47,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

View file

@ -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

View file

@ -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;
}
}

View file

@ -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()

View file

@ -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
1 seed1.testnet hetzner nbg1 195.201.35.33 cx23 2026-10-05T15:38:38Z
2 seed2.testnet hetzner ash 5.161.232.205 cpx21 2026-10-05T15:39:24Z
3 seed3.testnet hetzner sin 5.223.52.210 cpx22 2026-10-05T15:40:16Z

View file

@ -0,0 +1,3 @@
195.201.35.33:26811
5.161.232.205:26811
5.223.52.210:26811

View file

@ -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 <job id> 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; }
@ -53,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) {
@ -198,6 +210,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' });