Testnet go checklist for the re-cut (7 October 2026): the object section (interim hash 01294fd3..., digest 80af8aa1... pending mission item 8), the consequences per tier, the go table's new rows (the seeds' cut-over, proof retention, the forward-compatibility fields, the Windows prove host), the seeds' cut-over runbook (prepared, not run), the branches; infra/fast-time/testnet-object.mjs, the object at fast time on a devnet-suffix network (the first lock, the bonus split on both ledgers, a signed leave with the leaver stopped, class v4 at rung 0); testnet-object.json with subsidy_per_block_activation_daa 0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 08:33:44 +00:00
parent 7087fbb850
commit 37de380bb8
3 changed files with 435 additions and 11 deletions

View file

@ -3,6 +3,13 @@
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.
RE-CUT 7 October 2026 (the testnet genesis lane, on the project lead's approvals of 09:3x UK, `docs/plans/ledger-decisions.md`
"Decisions (7 October 2026, 09:3x UK)" row 5, and his words of the same morning on the two exec-side findings): the
genesis is re-cut on the 18-decimal layout with `EmissionSchedule::TESTNET_1` and every switch on from genesis; section
"The genesis, re-cut" below is the object, "The seeds' cut-over" the runbook. The three seeds still hold the 5 October
chain (genesis `87617621...`, digest `b7d8c915...`) at height 0 and move only on the project lead's go. The hash and digest are
INTERIM until mission item 8 (the genesis forward-compatibility fields, lane `genesis-forward`) lands in the object.
## What runs now
| Piece | Where | State at 16:05 UTC |
@ -28,23 +35,53 @@ igneumd/2.1.0
`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
## The genesis, re-cut (7 October 2026; INTERIM until mission item 8 lands)
| 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` |
| Coinbase message | `igneum-testnet-1 \| 2026-10-05 \| coins here have no value \| resets are announced` (unchanged from 5 October) |
| Coinbase subsidy field | 16 little-endian bytes, one IGN = 10^18 base units (the 18-decimal layout, O-2.6) |
| Timestamp | 1,791,158,400,000 ms = 2026-10-05T00:00:00Z (unchanged) |
| Bits | `0x1d100000` (unchanged) |
| Hash | `01294fd322704dc28fbef0e7a5ef86d6ee260ac5efaf88891cdba661b5fd58ac` |
| Merkle root | `bcd0e8fb1099aeb3cc1370b50e6dec9f615e204374b2097f25b2438e7d22f49f` |
| Consensus digest | `80af8aa18b9b1fd4dbe3c8640349573eb44ab518a7a90a99d8718647ebb53a9d` (09:5x UK, with `subsidy_per_block_activation_daa` 0; it was `9390d235...` before that field) |
| Base unit | 18 decimals (`base_unit_decimals` 18): one IGN is 10^18 base units, the EVM's wei; the bridge is the identity |
| Emission | `EmissionSchedule::TESTNET_1` at the unit: 100 IGN a block at 1 bps, a monthly glide with a two-year half-life, a 90-day ramp from 10 percent, a 1 percent of supply a year tail from the month the glide first pays under it; no hard cap |
| Switches on from genesis | `difficulty_v2_activation_daa` 0, `difficulty_v3_activation_daa` 0, `proving_v0_activation_daa` 0, `finality_v3_activation_daa` 0, `finality_daa_rule_activation_daa` 0, `finality_leave_activation_daa` 0 (`finality.leave_delay` 3,600), `signing_bonus_activation_daa` 0 with `signing_bonus_bps` 1,000 (no burn; vote-or-burn is out of the tree, 420f9305), `program_class_v3_activation_daa` 0, `program_class_v4_activation_daa` 0 with signal window 0 (v4 unconditional), `latency_ladder_activation_daa` 0 at rung 0 with `latency_ladder_window_daa` 86,400 and the six rungs 27 (admissible), 35 (admissible), 53 (admissible), 88 (inadmissible: quiet-core re-measure 08:46 UK, 10.85 ms cold with the sibling loaded, over the 10 ms gate), 173 and 267 (inadmissible), `fees_v1_activation_daa` 0 (`FeeParams::CALIBRATED_V1`), `proving_consensus_verify_daa` 0 under shard program id `0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a` and aggregator id `0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896` (`proving/igneum-prove/elf/manifest.json`, pinned 2026-10-05T16:20:38Z), `subsidy_per_block_activation_daa` 0 (each merged block credited the subsidy of its own DAA on the EVM side, as the UTXO coinbase pays it) |
| Left at never, by design | `fork_gate_activation_daa` (window 600), `proving_v1_activation_daa` and `proving_v1_fresh_rule_daa`, `exec_restart_*` |
| Finality object | `FinalityParams::MAINNET`: interval 30, depth 60, window 2,592,000 DAA, dust 100, presence 240, 8 aggregators, ban 2,592,000, min DAA 2,592,000, fold 6, leave delay 3,600 |
| 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` |
| Override file | refused on the testnet (as on mainnet): the object above is compiled into `TESTNET_PARAMS`; `infra/seed-nodes/testnet-object.json` is the same object as override-file JSON (`print_testnet_object`), for reading and diffing against the daemon's start-up lines, never loaded |
| Where it lives | node fork branch `testnet-genesis-2-node` on the box mirror (`release-0.3.18-node` e69e8a39, the decimals branch df2fbd03 merged, the vote-or-burn removal 420f9305, the exec-side fixes c7ea1e21 and d840537b, the re-cut); repository branch `testnet-genesis-2` |
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).
Void: the 5 October genesis `87617621...` (8-byte subsidy layout; the chain the seeds hold until the cut-over), its digest `b7d8c915...`, the proposals `52a3e6a9...` and `494fc9a3...`, and the interim digest `9390d235...`. Any node built before the re-cut never completes a handshake with a re-cut node (different genesis, different digest; seen 7 October 2026, 08:05 UK: a box node on the re-cut tree dialled the three live seeds and each side refused the other on the digest).
What a node prints at start on this object (igneum-build-1, 7 October 2026, 08:05 UK, the two-node gate's node A):
```
Latency ladder active: rungs 27, 35, 53, [88], [173], [267] shadow passes, this node signals none (header version bits 15 and 14)
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
Base unit: 10^18 base units per IGN (wei, the EVM's unit); one base unit is 1 wei on the execution layer
Consensus params digest: <the digest above> (exchanged in the p2p handshake; a peer with another digest is refused)
Finality v2 (igneum-testnet-1): interval 30 depth 60 window 2592000 DAA dust 100 presence 240 aggregators 8 ban 2592000 fold 6; rule v3 (frozen table, certificate fold) from checkpoint DAA 0; ... C1 in DAA seconds
Proving: consensus proof verification from DAA score 0 (shard program id 0x2b1a81cb..., aggregator id 0x474678f3...); a carried record whose proof fails invalidates its block
```
## Consequences of the object, per tier (the standing rule of 5 October 2026)
| Number | What it means | Who it touches |
|---|---|---|
| 18 decimals, 16-byte amounts | a node holds about 8 percent more UTXO bytes on disk and 11 percent more in memory than at 8 (docs/design/base-unit.md section 5: +80 MB disk, +170 MB memory at 10,000,000 UTXOs); no tier changes class on 8 GB to 32 GB cards, Windows, Linux or macOS; wallets and exchanges see Ethereum's unit | every node; nothing for hash rate, power or a miner's deadline |
| 100 IGN a block, a 90-day ramp from 10 percent | day-0 block pays 10 IGN (8 to the producer, 2 to the proving pool); the first full-rate block is above u64::MAX in base units, which is why every amount is u128 | every miner's expectation of the first month |
| the signing bonus at 1,000 bps from genesis | a vote key that stops signing while in the weight table earns 72 percent of the subsidy instead of 80; a home miner on one card who runs the app as shipped signs every checkpoint and is untouched; a pool user's key is the pool's | silent keys only; both ledgers agree after c7ea1e21 |
| `proving_consensus_verify_daa` 0 | every node verifies carried proof records from block one; a Windows node verifies through the installed `igneum-prove-host` and refuses to start without it (the in-process SP1 verifier is Unix-only); the seeds are Linux and verify in process; PC 1 and every Windows miner need the host beside the node (the app ships it) | Windows nodes; the go list below |
| the latency ladder from genesis at rung 0 | the shadow block costs 27 passes as class v4 ships; a step to rung 1 needs 90 percent in each of seven day-long windows, so nothing moves in the first week; rungs 4 and 5 can never be entered | every miner's hash rate is the class v4 rate; verifiers under 10 ms cold at rungs 0 to 2 |
| the leave item from genesis, delay 3,600 | a miner that stops cleanly sends a leave and is out of every denominator an hour later; one that stops without a word holds its weight in the table for the 30-day window | pool operators and rigs that restart; the app sends the leave on a clean stop |
| `FinalityParams::MAINNET` | the first lock needs 30 days of weight, so the testnet shows no lock in its first month and the exec-side identity under the bonus can only be read after it; the devnet-suffix fast-time line is the proof of that identity before go | everyone reading the finality page in the first month |
## Branches and commits (nothing pushed, nothing merged)
@ -53,6 +90,8 @@ has the old genesis and never completes a handshake with the seeds (different ge
| 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`) |
| node fork (`vendor/igneum-node-testnet-genesis` in `igneum-wt-testnet-genesis`, from `release-0.3.18-node` e69e8a39; on the box mirror) | `testnet-genesis-2-node` | see the branch | the re-cut genesis and object (section "The genesis, re-cut"), the decimals branch df2fbd03 merged, 420f9305 (vote-or-burn out), c7ea1e21 and d840537b (the exec-side identity under the bonus and the per-block subsidy) carried in Amount, `igneum_testnet_identity` pins the hash and the digest, `print_testnet_object` prints the object |
| app repository (`igneum-wt-testnet-genesis`, from master ab99e5e3) | `testnet-genesis-2` | see the branch | `infra/seed-nodes/testnet-object.json`, `tools/fleet/base-unit-gate.sh` (from the decimals branch), `infra/fast-time/testnet-object.mjs` (the object at fast time: class v4, the ladder at rung 0, the first lock, the bonus split on both ledgers, a signed leave), `igneum-pow` at the 0.3.18 release line, `docs/testnet/README.md`, this file |
| 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
@ -65,7 +104,7 @@ Mac: 27 passed (the crate is not in the PC build inputs). The packager's self-te
| # | 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 |
| 2 | Merge `testnet-genesis-2` (the repository branch: the object record, the gate, the fast-time harness, these docs) and `testnet-app` to master; the fork's `testnet-genesis-2-node` into the release branch the 0.4.0 cut uses (0.3.19 or later; `testnet-infra` on the fork is superseded by it) | the release engineer, on the project lead's word | NOT DONE: branches ready (7 October 2026), nothing merged; waits for mission item 8 (the forward-compatibility fields) |
| 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 |
@ -73,11 +112,32 @@ Mac: 27 passed (the crate is not in the PC build inputs). The packager's self-te
| 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 |
| 9a | The seeds' cut-over to the re-cut genesis (the runbook below): the re-cut binary on the three seeds, each data directory wiped (the 5 October chain at height 0 has nothing to keep), the digest line read back on each, the mesh re-formed | the infrastructure engineer, on the project lead's go | NOT DONE: binaries built, nothing deployed; the seeds hold the 5 October chain |
| 9b | Proof retention: every node keeps proofs for the pruning window and the carried-proof rule applies only above the pruning point, so a node joining after the pool's horizon syncs from the pruning point (the coordinator's direction, 7 October 2026, 10:0x UK); the node lane designs it, the testnet lane gates it with a fresh join after the retention has passed on the fast-time network | the node lane, then this lane | NOT DONE: found 7 October 2026 by the 0.3.18 canary |
| 9c | Mission item 8: the genesis forward-compatibility fields (the sig_scheme byte, the W5 key-succession item, the cache rung on the ladder), lane `genesis-forward`; the hash and digest above move once more when they land | lane `genesis-forward`, then this lane re-cuts | NOT DONE |
| 9d | Windows: `igneum-prove-host` beside every Windows node (verification from genesis); PC 1's node refuses to start without it | the release engineer (the 0.4.0 package) | NOT DONE: to check in the 0.4.0 Windows installer |
| 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`; note the young-window join fault: a node that joins a chain younger than its finality window sees `synced=False` until blocks pass genesis, which is the no-blocks state, not a fault | 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").
## The seeds' cut-over to the re-cut genesis (prepared 7 October 2026; runs only on the project lead's go)
Nothing below has been run against a seed. The seeds take no override file, so the cut-over is one binary and one
wipe per seed. Order: seed 1 last (it serves the public RPC), the other two first, an hour between is not needed (the
chain is at height 0 and no miner exists).
| # | Step | Command (from `infra/seed-nodes`, `NET=testnet`) | Read back |
|---|---|---|---|
| 1 | Build the seed binary from the fork branch `testnet-genesis-2-node` on the box for Debian 13 (glibc 2.41 on the seeds; a native 2.39 build also runs there, the 5 October seeds took a PC build at 2.39) | `cd vendor/igneum-node-testnet-genesis && ../../tools/build-remote.sh --ship seed` (artefacts in `target-remote/`), copy `igneumd` and `igneum-miner` into `infra/cross/out/` | `igneumd --version` names the commit; `sha256sum` recorded in this file at the go |
| 2 | Stop the unit and wipe the data directory on seed 2 and seed 3 (the 5 October chain is genesis alone) | `ssh root@<seed> 'systemctl stop igneumd && rm -rf /var/lib/igneum/igneum-testnet-1'` (the directory name is the network id under APPDIR; check with `ls /var/lib/igneum` first; wipe only that directory) | the directory is gone |
| 3 | Install the binary and start | `NET=testnet BUILD_WHERE=cross ./provision-seed.sh seed2.testnet`, then `seed3.testnet` (it uploads `infra/cross/out/igneumd`, refuses a glibc the seed cannot run, writes the unit and starts it) | the unit's log: `Base unit: 10^18`, `Consensus params digest: <the digest in "The genesis, re-cut">`, `genesis 01294fd3...` in the `[igneum-exec]` line, `Proving: consensus proof verification from DAA score 0`, `Latency ladder active` |
| 4 | Seed 1: the same two steps, then the public RPC check | `NET=testnet ./health.sh`; `curl https://rpc.testnet.igneum.network -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'` reads `0x0`; `eth_chainId` reads `0x116e` | three seeds, 2 peers each, height 0, every digest line equal |
| 5 | The cross-check that the old chain is gone | a 5 October binary (`igneumd 1c19441d`) pointed at a seed is refused at the handshake on the digest | one refusal line in the seed's log |
| 6 | Record | the digest, the genesis hash and the three sha256 lines in this file and in `docs/testnet/README.md`; the announcement text carries the genesis hash | |
What the project lead's go needs from him, in order: (a) the word that mission item 8 is in the object (or that the cut goes without it, which means a reset later); (b) "cut the seeds over" (this runbook, about 20 minutes for the three); (c) the 0.4.0 cut from the merged branches (step 3 of the go table); (d) the first miner. Until (b) the seeds keep the 5 October chain and refuse every re-cut node, which is harmless: nothing mines on either.
## Cost
| Item | USD per month, net (Hetzner API, 5 October 2026) |

View file

@ -0,0 +1,363 @@
#!/usr/bin/env node
// igneum-testnet-1's object at fast time (the testnet genesis lane, 7 October 2026): a 3-node devnet-suffix network on
// override-60x.json with every switch of infra/seed-nodes/testnet-object.json on from genesis, as the testnet carries
// them (difficulty v3, the DAA-second finality rule, finality v3, the signed leave item, the signing bonus at 1,000 bps,
// class v4 unconditional, the latency ladder active at rung 0, consensus proof verification under the pinned ids, fees
// v1, EmissionSchedule::TESTNET_1), real CPU mining on every node (igneum-pow engine, CPU genesis bits), two voting keys
// and one key that never votes (--no-vote). What the testnet itself cannot show inside a run (its weight window is 30
// days) this run shows in minutes: the first lock, a silent producer paid the bonus split, and a signed leave.
//
// What stays the devnet's, by design: the base unit (8 decimals: the devnet genesis payload is 8 bytes and the daemon
// refuses a unit its genesis does not parse at; the 18-decimal form is tools/fleet/base-unit-gate.sh on the testnet
// params), the finality object (the 60x file's: window 120, min_daa 120, presence 1, leave delay 60 = 3,600 / 60), the
// ladder window (60 DAA, one epoch, in place of 86,400) and the PoW schedule (epoch 60, lead 10, day 24 minutes).
// The testnet schedule is written at the devnet's unit (100 IGN a block = 10^10 sompi).
//
// Ports 29730 and up, network igneum-devnet-973, data under IGNEUM_TN_TMP (default /tmp/igneum-fast-time-testnet). The
// live devnet, Devnet 2 and the public testnet seeds are never touched (--nodnsseed, loopback only). Everything started
// is stopped at the end, by pid, never by name.
//
// Checks (each a line in the summary; PASS is every one true):
// every node prints the start-up lines of the object (digest, class v4, ladder active, proof verification from 0, fees v1,
// the signing bonus from 0, the leave item from 0)
// every epoch's template is class v4 at rung 0 (27 shadow passes); no ladder bits on the chain (no node signals); the
// object byte of every mined block is 0; the sinks and block counts agree across the nodes
// the first finality lock arrives (finality_active, latest_locked_index > 0) on every node
// the bonus pays: after the first lock, a chain block whose mergeset holds a block of the silent key pays that key's
// address 72 percent of the merged block's subsidy on the UTXO side and the pool 28 percent (the voting keys 80/20)
// the bridge is the identity under the bonus: for every chain block after the first lock, the sum of the UTXO coinbase
// outputs to each miner's address, times 10^10, equals the sum of the execution layer's segment rewards to that
// miner's EVM address (igneum_getSegment). Before the exec-side fix of 7 October 2026 this is the known-failed case:
// the executor credits 80 percent to a silent key while the coinbase pays 72 (the testnet lane's finding, 08:0x UK)
// a leave: at --leave-at seconds a voting key submits its signed leave (igneum-miner leave); the node accepts it, and
// within leave_delay + one checkpoint the checkpoints' voter count drops by one
//
// node infra/fast-time/testnet-object.mjs [--secs 600] [--leave-at 360] [--expect pass|bonus-fails]
// IGNEUMD, IGNEUM_MINER, IGNEUM_POW name the binaries (defaults: the testnet genesis worktree's fork under
// vendor/igneum-node-testnet-genesis/target/release and igneum-pow/target/release/igneum-pow, the layout on
// igneum-build-1 under /srv/builds/igneum-wt-testnet-genesis).
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
import { devAddress } from '../../tools/harness/lib/address.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const OBJECT = `${ROOT}infra/seed-nodes/testnet-object.json`;
const BIN = process.env.IGNEUM_TN_BIN || `${ROOT}vendor/igneum-node-testnet-genesis/target/release`;
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
const IGNEUM_POW = process.env.IGNEUM_POW || `${ROOT}igneum-pow/target/release/igneum-pow`;
const TMP = process.env.IGNEUM_TN_TMP || '/tmp/igneum-fast-time-testnet';
const BASE = +(process.env.IGNEUM_TN_BASE_PORT || 29730), SUFFIX = +(process.env.IGNEUM_TN_SUFFIX || 973);
const RUNG0 = 27, SOMPI = 10n ** 8n, WEI_PER_SOMPI = 10n ** 10n;
const args = process.argv.slice(2);
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? +args[i + 1] : dflt; };
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
const SECS = flag('secs', 600);
const LEAVE_AT = flag('leave-at', 360);
const EXPECT = sflag('expect') || 'pass';
if (!['pass', 'bonus-fails'].includes(EXPECT)) { console.error('usage: [--secs 600] [--leave-at 360] [--expect pass|bonus-fails]'); process.exit(2); }
const started = [];
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
if (!existsSync(OBJECT)) { console.error(`missing ${OBJECT} (print_testnet_object writes it)`); process.exit(2); }
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
// u64::MAX is not a JavaScript number: the files are merged as text, never through JSON.parse of the whole object
const baseText = readFileSync(FILE, 'utf8');
const objectText = readFileSync(OBJECT, 'utf8');
const num = (text, name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(text); return m ? m[1] : undefined; };
const str = (text, name) => { const m = new RegExp(`"${name}":\\s*"([^"]*)"`).exec(text); return m ? m[1] : undefined; };
const EPOCH = +num(baseText, 'pow_epoch_blocks'), LEAD = +num(baseText, 'pow_epoch_lead'), DAY_MS = +num(baseText, 'pow_day_ms');
const LEAVE_DELAY = 60, LADDER_WINDOW = 60;
// the switches as the object carries them, the clocks at fast time, the unit the devnet's
const switches = {
genesis_bits: String(GENESIS_BITS), skip_proof_of_work: 'false',
difficulty_v2_activation_daa: num(objectText, 'difficulty_v2_activation_daa'),
difficulty_v3_activation_daa: num(objectText, 'difficulty_v3_activation_daa'),
proving_v0_activation_daa: num(objectText, 'proving_v0_activation_daa'),
finality_v3_activation_daa: num(objectText, 'finality_v3_activation_daa'),
finality_daa_rule_activation_daa: num(objectText, 'finality_daa_rule_activation_daa'),
finality_leave_activation_daa: num(objectText, 'finality_leave_activation_daa'),
signing_bonus_activation_daa: num(objectText, 'signing_bonus_activation_daa'),
signing_bonus_bps: num(objectText, 'signing_bonus_bps'),
program_class_v3_activation_daa: num(objectText, 'program_class_v3_activation_daa'),
program_class_v4_activation_daa: num(objectText, 'program_class_v4_activation_daa'),
program_class_v4_signal_window_daa: num(objectText, 'program_class_v4_signal_window_daa'),
latency_ladder_activation_daa: num(objectText, 'latency_ladder_activation_daa'),
latency_ladder_window_daa: String(LADDER_WINDOW),
fees_v1_activation_daa: num(objectText, 'fees_v1_activation_daa'),
proving_consensus_verify_daa: num(objectText, 'proving_consensus_verify_daa'),
proving_shard_program_id: JSON.stringify(str(objectText, 'proving_shard_program_id')),
proving_aggregator_id: JSON.stringify(str(objectText, 'proving_aggregator_id')),
};
for (const [k, v] of Object.entries(switches)) if (v === undefined) { console.error(`the object has no ${k}`); process.exit(2); }
const ladderList = /"latency_ladder":\s*(\[[\s\S]*?\])/.exec(objectText)[1].replace(/\s+/g, '');
const feesObject = /"fees":\s*(\{[\s\S]*?\n \})/.exec(objectText)[1].replace(/\s+/g, '');
const emissionObject = /"emission":\s*(\{[\s\S]*?\n \})/.exec(objectText)[1].replace(/\s+/g, '').replace(/"launch_rate":"(\d+)"/, (_, r) => `"launch_rate":"${BigInt(r) / WEI_PER_SOMPI}"`);
// the value of a top-level key in the file's text, nested objects and lists included (brace matching, never
// JSON.parse: u64::MAX is not a JavaScript number)
function spanOf(text, key) {
const m = new RegExp(`"${key}":\\s*`).exec(text);
if (!m) return null;
const start = m.index; let i = m.index + m[0].length;
const c = text[i];
if (c === '{' || c === '[') {
const open = c, close = c === '{' ? '}' : ']'; let depth = 0;
for (; i < text.length; i++) { if (text[i] === open) depth++; else if (text[i] === close) { depth--; if (depth === 0) { i++; break; } } }
} else if (c === '"') { i = text.indexOf('"', i + 1) + 1; } else { while (i < text.length && !/[,}\n]/.test(text[i])) i++; }
// the trailing comma and the line break before the key
if (text[i] === ',') i++;
let s = start; while (s > 0 && /\s/.test(text[s - 1])) s--;
return [s, i];
}
function mergeOverrideText(text, fields) {
let out = text;
for (const k of Object.keys(fields)) { const span = spanOf(out, k); if (span) out = out.slice(0, span[0]) + out.slice(span[1]); }
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${v}`).join(',\n ');
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
}
// the finality object: the 60x file's, with the leave delay at fast time
const finSpan = spanOf(baseText, 'finality');
const fastFinality = baseText.slice(finSpan[0], finSpan[1]).replace(/^[\s,]*"finality":\s*/, '').replace(/,\s*$/, '').replace(/\s+/g, '');
const finalityObject = fastFinality.includes('leave_delay') ? fastFinality.replace(/"leave_delay":\d+/, `"leave_delay":${LEAVE_DELAY}`) : fastFinality.replace(/\}$/, `,"leave_delay":${LEAVE_DELAY}}`);
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, { ...switches, latency_ladder: ladderList, fees: feesObject, emission: emissionObject, finality: finalityObject }));
log(`object: ${OBJECT}; every switch from genesis; finality ${finalityObject}; ladder window ${LADDER_WINDOW}; emission ${emissionObject}; run ${SECS} s, leave at ${LEAVE_AT} s; expect ${EXPECT}`);
const LABELS = ['tn-voter-a', 'tn-voter-b', 'tn-silent'];
const EVM = ['00000000000000000000000000000000000000a0', '00000000000000000000000000000000000000a1', '00000000000000000000000000000000000000a2'];
// each miner's payout address as the miner itself prints it (its first log line names it); devAddress(label) is the
// harness's own derivation and is not the miner's, so the chain's outputs are matched against the printed one
let ADDR = LABELS.map(l => devAddress(l));
function addressesFromMinerLogs() {
return [0, 1, 2].map((i, k) => { const m = minerLog(i).join('\n').match(/igneum(?:dev|test)?:[a-z0-9]{20,}/); return m ? m[0] : ADDR[k]; });
}
class Node {
constructor(i, connect = []) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
started.push(this.proc);
await sleep(1500);
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort} evm ${this.evmPort}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
async evm(method, params) {
const r = await fetch(`http://127.0.0.1:${this.evmPort}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
return (await r.json()).result;
}
}
function miner(bin, argv, name, env = {}) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
started.push(p);
return p;
}
async function stopAll() {
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
const minerLog = (i) => { try { return readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n'); } catch { return []; } };
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const n0 = await new Node(0).start();
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start();
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start();
const nodes = [n0, n1, n2];
const START_LINES = {
digest: /Consensus params digest: ([0-9a-f]{64})/,
class_v4: /program class v4|class v4 from|Program class: v4|class_v4/i,
ladder_active: /Latency ladder active: rungs/,
proof_verification_from_0: /Proving: consensus proof verification from DAA score 0/,
fees_v1: /calibrated v1 from DAA score 0/,
finality_v3_and_c1: /rule v3 \(frozen table, certificate fold\) from checkpoint DAA 0.*C1 in DAA seconds/,
};
for (const n of nodes) log(`n${n.i}: ${Object.entries(START_LINES).map(([k, re]) => `${k}=${n.grepLog(re).length > 0}`).join(' ')} | digest ${(n.grepLog(START_LINES.digest)[0] || '').replace(/^.*?digest: /, '').slice(0, 16)}`);
// n0 and n1 vote (the key is the payout label's identity); n2 never votes: the silent key
const miners = nodes.map((n, i) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', LABELS[i], '--evm-address', EVM[i], '--dev-fee', '0', '--status-secs', '30', '--stall-secs', '0', ...(i === 2 ? ['--no-vote'] : [])], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) }));
const pay = devAddress('fast-time-testnet-object');
const epochs = new Map();
let lastEpoch = -1, lastReport = 0, lastDaa = 0, firstLock = null, leaveSent = null, leaveOutcome = null, votersBeforeLeave = null, votersAfterLeave = null, leaveSeenAt = null;
const samples = [];
const inactiveAfterLeave = [];
async function report(node, last = 50) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); }
while (Date.now() - t0 < SECS * 1000) {
await sleep(1000);
let daa = null, epoch = null, cls = null, reps = null, step = null, sig = null;
try {
const t = await n0.rpc.call('getBlockTemplate', { payAddress: pay, extraData: [] });
const pe = t.powEpoch || t.pow_epoch || {};
daa = pe.virtualDaaScore ?? t.block?.header?.daaScore; epoch = pe.epochIndex; cls = pe.programClass;
reps = pe.latencyLadderReps; step = pe.latencyLadderStep; sig = pe.latencyLadderSignal;
} catch (e) { log(`template: ${e.message}`); }
if (epoch != null && epoch !== lastEpoch) {
epochs.set(epoch, { class: cls, reps, step, firstSeenDaa: daa, at: +since() });
log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s: template class ${cls} rung ${step} (${reps} passes), this node signals ${sig}`);
lastEpoch = epoch;
}
lastDaa = daa ?? lastDaa;
const r = await report(n0, 5);
if (r && firstLock == null && r.latestLockedIndex > 0) { firstLock = { index: r.latestLockedIndex, daa, at: +since() }; log(`FIRST LOCK: index ${r.latestLockedIndex} at daa ${daa}, ${since()} s wall (active ${r.finalityActive})`); }
if (leaveSent == null && Date.now() - t0 >= LEAVE_AT * 1000 && firstLock != null) {
const before = await report(n1, 3);
votersBeforeLeave = before?.checkpoints?.at(-1)?.voters ?? null;
const out = spawnSync(CPU_MINER, ['leave', n1.grpc, LABELS[1]], { encoding: 'utf8', timeout: 20000 });
leaveSent = { at: +since(), daa, stdout: (out.stdout || '').trim().slice(0, 300), stderr: (out.stderr || '').trim().slice(0, 300) };
leaveOutcome = /LEAVE key=/.test(out.stdout || '') ? 'accepted' : 'refused';
log(`LEAVE by ${LABELS[1]} at daa ${daa}: ${leaveOutcome} (${leaveSent.stdout || leaveSent.stderr}); voters before ${votersBeforeLeave}; stopping its miner (a clean departure)`);
try { miners[1].kill('SIGINT'); } catch { }
}
if (leaveSent != null && votersAfterLeave == null) {
const after = await report(n0, 3);
const v = after?.checkpoints?.at(-1)?.voters ?? null;
if (v != null && votersBeforeLeave != null && v < votersBeforeLeave) { votersAfterLeave = v; leaveSeenAt = { at: +since(), daa }; log(`LEAVE TOOK EFFECT: voters ${votersBeforeLeave} -> ${v} at daa ${daa}, ${since()} s`); }
}
if (leaveSent != null && r && r.finalityActive === false) { inactiveAfterLeave.push({ at: +since(), daa, reason: r.finalityReason }); }
if (Date.now() - lastReport > 15000) {
lastReport = Date.now();
const counts = await Promise.all(nodes.map(async n => { try { const d = await n.rpc.call('getBlockDagInfo'); return `${d.blockCount}/${String(d.sink).slice(0, 8)}`; } catch { return '?'; } }));
log(`t=${since()} s daa ${daa} epoch ${epoch} class ${cls} rung ${step} (${reps}) lock ${r?.latestLockedIndex ?? '?'} active ${r?.finalityActive ?? '?'} blocks/sink per node ${counts.join(' ')}`);
samples.push({ t: +since(), daa, epoch, class: cls, step, reps, locked: r?.latestLockedIndex ?? null, active: r?.finalityActive ?? null, nodes: counts });
}
}
await sleep(3000);
// the chain: every block with its coinbase outputs by address, and the execution layer's segment rewards by miner
const dag = await Promise.all(nodes.map(async n => { try { return await n.rpc.call('getBlockDagInfo'); } catch (e) { return { error: e.message }; } }));
const genesis = dag[0].pruningPointHash;
async function allBlocks(n) {
const out = []; let low = genesis; const seen = new Set();
for (let round = 0; round < 500; round++) {
const r = await n.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: true });
const blocks = r.blocks || [];
let added = 0;
for (const b of blocks) {
const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h);
const coinbase = (b.transactions || [])[0];
const outputs = (coinbase?.outputs || []).map(o => ({ address: o.verboseData?.scriptPublicKeyAddress || '', value: BigInt(typeof o.value === 'string' ? o.value : Math.round(o.value)) }));
out.push({ hash: h, daa: +b.header.daaScore, version: +b.header.version, chain: !!b.verboseData?.isChainBlock, outputs }); added++;
}
if (!blocks.length || added === 0) break;
low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break;
}
return out;
}
let blocks = [];
try { blocks = await allBlocks(n0); } catch (e) { log(`getBlocks: ${e.message}`); }
ADDR = addressesFromMinerLogs();
const chainBlocks = blocks.filter(b => b.chain && b.daa > 0).sort((a, b) => a.daa - b.daa);
if (chainBlocks.length) log(`addresses: harness ${ADDR.map(a => a.slice(0, 24)).join(' ')}; first chain block outputs ${chainBlocks[0].outputs.map(o => `${o.address.slice(0, 24)}=${o.value}`).join(' ')}`);
// the execution layer: segment n is chain block n; rewards per miner in wei
const segments = [];
const tip = Number(BigInt(await n0.evm('eth_blockNumber', []).catch(() => '0x0')));
for (let i = 1; i <= tip; i++) { const s = await n0.evm('igneum_getSegment', [`0x${i.toString(16)}`]).catch(() => null); if (s) segments.push(s); }
const segByHash = new Map(segments.map(s => [String(s.hash).replace(/^0x/, '').toLowerCase(), s]));
const lockDaa = firstLock?.daa ?? Infinity;
// per chain block after the first lock: the UTXO outputs to each miner's address against the segment's rewards to its EVM address
const bridgeRows = [];
const bonusRows = [];
for (const b of chainBlocks) {
if (b.daa <= lockDaa) continue;
const seg = segByHash.get(b.hash.replace(/^0x/, '').toLowerCase());
if (!seg) continue;
const subsidyOf = (hash) => { const m = (seg.mergeset || []).find(m => String(m.hash).replace(/^0x/, '').toLowerCase() === hash); return m; };
for (let i = 0; i < 3; i++) {
const utxo = b.outputs.filter(o => o.address === ADDR[i]).reduce((s, o) => s + o.value, 0n);
const exec = (seg.rewards || []).filter(r => String(r.miner).replace(/^0x/, '').toLowerCase() === EVM[i]).reduce((s, r) => s + BigInt(r.wei), 0n);
if (utxo === 0n && exec === 0n) continue;
bridgeRows.push({ chain_daa: b.daa, chain: b.hash.slice(0, 12), miner: LABELS[i], utxo_sompi: String(utxo), exec_wei: String(exec), identity: utxo * WEI_PER_SOMPI === exec });
// the split: the merged blocks of this miner in the mergeset, their subsidies from the exec rewards' own count
const merged = (seg.rewards || []).filter(r => String(r.miner).replace(/^0x/, '').toLowerCase() === EVM[i]).length;
if (merged === 1 && utxo > 0n) {
// one merged block of this key: its subsidy is the table's at the merged block's DAA; the UTXO producer share tells the split
const m = (seg.mergeset || []).find(mm => (mm.miner ?? '').toString().toLowerCase().endsWith(EVM[i])) ?? null;
const pool = b.outputs.filter(o => o.address !== ADDR[0] && o.address !== ADDR[1] && o.address !== ADDR[2]).reduce((s, o) => s + o.value, 0n);
bonusRows.push({ chain_daa: b.daa, miner: LABELS[i], silent_key: i === 2, utxo_producer_sompi: String(utxo), exec_wei: String(exec), merged_daa: m?.daaScore ?? null });
}
}
}
// the bonus: a silent key's producer output is 72 percent of what a voting key's would be at the same subsidy; the two
// voting keys are the control at 80. Compare each silent row's UTXO producer share with the exec credit (80 percent by
// the executor's rule before the fix): under the bonus utxo = exec / 10^10 * 0.9 exactly (the bonus is 10 percent of the
// producer share), and under the fixed executor utxo * 10^10 = exec.
const silentRows = bonusRows.filter(r => r.silent_key);
const votingRows = bonusRows.filter(r => !r.silent_key);
const tenth = (x) => x / 10n;
const bonusPaidOnUtxo = silentRows.length > 0 && silentRows.every(r => {
const utxo = BigInt(r.utxo_producer_sompi), execSompi = BigInt(r.exec_wei) / WEI_PER_SOMPI;
// either the executor still pays the full share (utxo = 90 percent of it) or it pays the coinbase's split (equal)
return utxo === execSompi - tenth(execSompi) || (utxo === execSompi && utxo < BigInt(votingRows[0]?.utxo_producer_sompi ?? '0'));
});
const silentPaidLessThanVoters = silentRows.length > 0 && votingRows.length > 0 && silentRows.every(r => BigInt(r.utxo_producer_sompi) < BigInt(votingRows.reduce((m, v) => BigInt(v.utxo_producer_sompi) > BigInt(m) ? v.utxo_producer_sompi : m, '0')));
const bridgeIdentity = bridgeRows.length > 0 && bridgeRows.every(r => r.identity);
const bridgeIdentityVoters = bridgeRows.filter(r => r.miner !== LABELS[2]).length > 0 && bridgeRows.filter(r => r.miner !== LABELS[2]).every(r => r.identity);
const bridgeIdentitySilent = bridgeRows.filter(r => r.miner === LABELS[2]).length > 0 && bridgeRows.filter(r => r.miner === LABELS[2]).every(r => r.identity);
const objectBytes = blocks.filter(b => b.daa > 0).reduce((m, b) => { const v = (b.version >> 8) & 0x3f; m[v] = (m[v] || 0) + 1; return m; }, {});
const ladderBits = blocks.filter(b => b.daa > 0).reduce((m, b) => { const k = (b.version & 0x8000) ? 'up' : (b.version & 0x4000) ? 'down' : 'none'; m[k] = (m[k] || 0) + 1; return m; }, {});
const sinks = dag.map(d => String(d.sink || '?').slice(0, 16));
const counts = dag.map(d => d.blockCount ?? '?');
const accepted = [0, 1, 2].map(i => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length);
const rejectedNode = nodes.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i).length);
const finalReports = await Promise.all(nodes.map(n => report(n, 3)));
const checks = {
start_lines_on_every_node: nodes.every(n => Object.entries(START_LINES).every(([k, re]) => k === 'class_v4' || n.grepLog(re).length > 0)),
digest_equal_on_every_node: new Set(nodes.map(n => (n.grepLog(START_LINES.digest)[0] || '').replace(/^.*?digest: /, '').slice(0, 64))).size === 1,
every_epoch_class_v4_at_rung_0: epochs.size > 0 && [...epochs.values()].every(e => e.class === 4 && (e.step === 0 || e.step == null) && (e.reps === RUNG0 || e.reps == null)),
no_ladder_bits_and_object_byte_0: blocks.length > 0 && Object.keys(ladderBits).every(k => k === 'none') && Object.keys(objectBytes).every(v => +v === 0),
zero_rejected_by_nodes: rejectedNode.every(c => c === 0),
sinks_and_counts_agree: new Set(sinks).size === 1 && new Set(counts.map(String)).size === 1,
first_lock_on_every_node: firstLock != null && finalReports.every(r => r && r.latestLockedIndex > 0),
silent_key_mined_after_the_lock: silentRows.length > 0,
bonus_paid_on_the_utxo_side: silentPaidLessThanVoters && bonusPaidOnUtxo,
bridge_identity_for_voting_keys: bridgeIdentityVoters,
bridge_identity_for_the_silent_key: bridgeIdentitySilent,
leave_accepted: leaveOutcome === 'accepted',
leave_took_effect_within_delay: leaveSeenAt != null && leaveSent != null && (leaveSeenAt.daa - leaveSent.daa) <= LEAVE_DELAY + 30 + 5,
// the point of the leave item: a clean departure never holds finality for a window; the pause after the leave, if any,
// ends inside the delay plus one checkpoint, never a full window (120 DAA at fast time)
no_window_pause_after_the_leave: leaveSent != null && (inactiveAfterLeave.length === 0 || (Math.max(...inactiveAfterLeave.map(x => x.daa)) - Math.min(...inactiveAfterLeave.map(x => x.daa))) < 120),
finality_active_at_the_end: finalReports.every(r => r && r.finalityActive === true),
};
let pass;
if (EXPECT === 'bonus-fails') {
// the known-failed case: the executor before the fix pays the silent key the full share, every other check holds
pass = Object.entries(checks).every(([k, v]) => k === 'bridge_identity_for_the_silent_key' ? v === false : v);
} else {
pass = Object.values(checks).every(Boolean);
}
const summary = {
pass, expect: EXPECT, checks, node: IGNEUMD, miner: CPU_MINER, override, run_secs: SECS, final_daa: lastDaa,
epochs: Object.fromEntries([...epochs.entries()]), first_lock: firstLock,
leave: { sent: leaveSent, outcome: leaveOutcome, voters_before: votersBeforeLeave, voters_after: votersAfterLeave, seen_at: leaveSeenAt, delay_daa: LEAVE_DELAY, inactive_after: inactiveAfterLeave },
blocks: { total: blocks.length, chain: chainBlocks.length, segments: segments.length, object_bytes: objectBytes, ladder_bits: ladderBits },
bridge_rows: bridgeRows, bonus_rows: bonusRows, accepted_per_miner: accepted, rejected_by_nodes: rejectedNode, sinks, block_counts: counts, samples,
final_reports: finalReports.map(r => r && { active: r.finalityActive, reason: r.finalityReason, locked: r.latestLockedIndex, voters: r.checkpoints?.at(-1)?.voters ?? null }),
};
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}): epochs ${[...epochs.entries()].map(([e, v]) => `e${e}:v${v.class}:r${v.step}`).join(' ')}; first lock ${firstLock ? `index ${firstLock.index} at daa ${firstLock.daa}` : 'none'}; silent rows ${silentRows.length} (${silentRows.slice(0, 3).map(r => `utxo ${r.utxo_producer_sompi} exec ${r.exec_wei}`).join('; ')}); bridge rows ${bridgeRows.length}, voters identity ${bridgeIdentityVoters}, silent identity ${bridgeIdentitySilent}; leave ${leaveOutcome} voters ${votersBeforeLeave} -> ${votersAfterLeave}; blocks ${blocks.length} chain ${chainBlocks.length}; rejected ${rejectedNode.join('/')}; sinks ${sinks.join(' ')} at ${counts.join('/')}`);
for (const [k, v] of Object.entries(checks)) if (!v) log(`${EXPECT === 'bonus-fails' && k === 'bridge_identity_for_the_silent_key' ? 'EXPECTED FAILED CHECK' : 'FAILED CHECK'} ${k}`);
log(`summary: ${TMP}/summary.json`);
await stopAll();
process.exit(pass ? 0 : 1);

View file

@ -66,6 +66,7 @@
"finality_daa_rule_activation_daa": 0,
"fork_gate_activation_daa": 18446744073709551615,
"fork_gate_window_daa": 600,
"subsidy_per_block_activation_daa": 0,
"signing_bonus_activation_daa": 0,
"signing_bonus_bps": 1000,
"finality_leave_activation_daa": 0,