Merge txgen: the devnet transaction generator, proving watch, exporter block reconstruction fix with node-plan fixtures, bench log and evidence

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 16:22:26 +00:00
commit 142ca73731
14 changed files with 3961 additions and 22 deletions

View file

@ -1401,3 +1401,37 @@ Fix: the guests are pinned build artefacts (`proving/igneum-prove/elf/`: both EL
Before: 127.0 s wall per proof on the Mac (the node saw 114 s to 139 s). After: 1.8 s to 2.4 s wall, under the 2 s target for the verify call itself; the remaining 1.3 s to 2.1 s is SP1's light verifier construction plus paging a 58 MB binary under load, and would shrink in a long-lived verifier process. Unit tests (`cargo test -p igneum-prove-host --bin igneum-prove-host`): the embedded files hash to the manifest, the embedded keys derive the manifest's ids, a changed file is refused; the ignored test re-runs SP1's setup on the embedded ELFs and gets the pinned ids. `tools/ci/pinned-guests-check.sh` was shown failing on an empty `elf/` and passing on the pinned one.
What every machine must do: the pinned shard id `0x0dfade07...be16a` differs from every id now running (Mac `0x0559759b...`, PC 2 `0x05db1aca...`), so this is a guest change for the whole devnet, and proofs in flight at the switch are rejected by a verifier that has moved. Rollout order (proving/README.md, "Pinned guest programs"): provers off on every machine; wait until `igneum_getProvingStatus` shows an empty pool on every node; install the host built from this `elf/` on every node (Mac DMG; PCs through `igneum-prove-wsl2.zip`, whose package carries `elf/`, so the WSL build embeds the same files); confirm `igneum-prove-host --mode id` prints the same shard id everywhere; provers back on. From then on a differing id is impossible without a change to the committed `elf/`.
## 5 October 2026 (afternoon), live devnet: real transactions, the first non-empty shard proven and paid, and the exporter's block structure fixed (execution engineer)
Until this run the devnet had carried no transaction at all, so every one of the 349 shards paid before 15:35 UTC was empty (0 pgas). `tools/txgen/run.mjs` (new; viem for EIP-1559 signing, otherwise Node 22 built-ins) funds generated wallets from the devnet dev-fee key (`~/.config/igneum/dev-fee-devnet.json`, keys of the generated wallets in `~/.config/igneum/txgen/wallets.json`, mode 0600) and sends transfers between them at a steady rate through one node's EVM RPC; `tools/txgen/proving-watch.mjs` samples the proving layer during a run and builds the per-block report afterwards. Both runs went through the Mac node (127.0.0.1:26800) under `tools/lock/with-lock.sh run`, with PC 2's RTX 5090 (app 0.3.8, SP1 CUDA under WSL2) as the only prover and the Mac node as the verifier. Chain id 4463, gas price quote 3 gwei (1 gwei execution base, 1 gwei proving base at ratio 1.0, 1 gwei tip), `eth_estimateGas` 25,380 for a transfer.
| Run | Window (UTC) | Wallets | Sent | Included | Included per s | Latency p50 / p90 / max (s) | Blocks with content | Transfers per content block p50 / p90 / max | Failures | Fees paid (IGN) |
|---|---|---|---|---|---|---|---|---|---|---|
| 1, master code | 15:37:30 to 15:57:30 | 16 | 2,275 | 2,161 (103 pending at the cut, all with a receipt 20 s later) | 1.86 | 40.7 / 110.8 / 209 | 59 | 8 / 92 / 224 | 60: 49 "replacement underpriced", 10 dropped, 1 skipped (9 of the 11 executed later, see below) | 0.091 |
| 2, fixed code | 15:59:34 to 16:14:34 | 16 | 1,650 | 1,633 (17 pending at the cut) | 1.71 | 45.7 / 128.7 / 253 | 48 | 10 / 99 / 224 | 0 (0 nonce retries, 0 deferred) | 0.069 |
Funding: 16 wallets at 2 IGN each, 16 IGN from the dev-fee address, which held 891 IGN before and 997 IGN after (it receives 1 block reward in 100 from every fee-paying miner). The wallets end with 31.83 IGN; the whole spend of the afternoon is 16.16 IGN.
What paced inclusion. There is no p2p relay of EVM transactions (execution-layer ledger item 9), so only blocks produced by the Mac's own miner carry what the Mac node's RPC received. The Mac miner had 134 blocks accepted in run 1's window and 59 of them carried transfers: the pool hands a sender's contiguous run to one template and then withholds that sender for 4 s (`pool.rs HANDOUT_COOLDOWN`), templates are rebuilt on every tip change (about one a second, 3,888 switches in the miner's counters), so a sender is mineable about 1 s in 5 and inclusion comes in bursts (quiet 50 s stretches, then a Mac block with 205 or 224 transfers). Measured before run 1 started: 12 of the 14 Mac blocks found while the 8 funding transfers waited 124 s were empty. The pool refuses a nonce more than 16 ahead of the account (`MAX_NONCE_GAP`), so 8 wallets at 2 per second would hit the gap; 16 wallets keep every queue under 14. Design rule 463 already names this cooldown as a stand-in until the executor listens to block-added.
The two tool defects run 1 found, fixed before run 2. (1) A transaction judged "not in the pool and in no block" 90 s after the send was counted dropped and the wallet's nonce re-synced to its `latest` nonce; the verdict is transient around a one-block selected-chain reorg (50 in the window, one every 8 s, the carrying block is re-merged seconds later), and the re-sync made the next send reuse a nonce already queued, which the pool refused as "replacement underpriced" (49 times). Now a drop needs two such verdicts 60 s apart and every re-sync reads the node's pending nonce (`eth_getTransactionCount` with `pending`, `pool.pending_nonce`). The wallets' chain nonces show 2,273 of run 1's 2,275 transfers executed, so 9 of the 11 "lost" verdicts were premature. (2) "nonce gap too large" and "too many queued" are back-pressure, not nonce errors: counted deferred, no retry.
| Proving during run 1 (node log 15:35:25 to 16:08:31 UTC, PC 2 app log by collect job) | Measured |
|---|---|
| Proof records accepted / verified / rejected / paid lines | 36 / 36 / 0 / 43 (7 records' "paid" line logged twice at the same carrying block after a reorg re-executed it; `paidShards` moved by exactly 36, no double payment) |
| Shards with pgas > 0 among them | 1: block 72704 shard 0, 29 transfers, 5,800 pgas, 609,000 gas (the prover takes the newest unpaid assigned shard, `prover.rs choose`; content blocks were 59 of about 1,400 in the window) |
| Block 72704 timeline | chain block executed 15:43:29.9; PC 2 assigned 15:43:43; proven and submitted in 34 s; record accepted by the Mac 15:44:17 (47 s after execution); verified in 2.3 s wall, 0.297 s in the verifier; paid 1.7623286 IGN at chain block 72744 (15:44:22) |
| PC 2 prove+submit time, empty shards in the window (n=34) | p50 28 s, 27 to 29 s; 32 to 49 s for 73166 to 73339 while the housekeeping job `build-hk-tests-1` (five node suites and the app tests) ran on PC 2 from 15:54:13 |
| Verify wall time on the Mac, p50 / max | 1.6 s / 6.8 s (the verifier itself 0.05 to 0.30 s) |
| Payout per shard | the segment's pool credit, 0.8813 IGN per mergeset block (20% of the 4.407 IGN block reward), divided by its shards: 0.88, 1.76 or 2.64 IGN for 1, 2 or 3 blocks; content changes nothing in v0 (`proving.rs shard_payouts`) |
| Content shard that failed | block 72803 shard 0: 7 copies skipped as NonceTooLow (duplicates from parallel blocks), 0 executed, 1,400 pgas; PC 2: "record refused: statement 0xa1ac35ee... is not the native statement 0xa00dfb3f... (native-execution veto)"; never proven |
| Run 2 | 5 shards proven, all empty, 28 to 30 s; at 16:02:28 the coordinated 0.3.9 rollout switched PC 2's prover off (`prove-off-pc2-039`), so run 2 had a prover for its first 3 minutes only |
The 72803 cause, in the exporter, not the core. The core's tx accumulator (`executor.rs Carry::absorb`) hashes every transaction's including miner and blue flag, skipped copies included, and the link hashes the block index; the node enumerates every mergeset block, empty ones included (`exec/executor.rs`), with the chain block itself last. The export (`igneum_exportSegments`) listed entries per block as executed-then-skipped with no position, no empty blocks and no miner on a skipped copy, and `igneum-prove-export blocks_of` sorted them by sequence (every skipped copy behind every executed one), merged consecutive blocks of one miner, dropped the empty blocks and gave a block of skipped copies only the previous block's miner or the zero address. Shown on the Mac with the old exporter binary (built from master at 16:03): block 72803 rebuilt under the zero address, link_out 0x362fef10... against the node's 0x111a55ac...; block 72854 (an empty block before the 205 transfers, no skipped copy) link_out 0xd34e57b7... against the node's 0x85f27dc1..., roots equal. 72704 verified only because its transaction block came first.
The fix, both sides ours. Fork (`vendor/igneum-node-txgen`, branch `txgen-export`, `exec/src/rpc.rs`): the export names the mergeset per segment ("blocks": hash, miner, blue, txCount, empty blocks included) and gives every entry "block" and "position" from the executor's boundaries (body order), skipped copies with their block's miner; records without boundaries keep the old shape. Exporter (`proving/igneum-prove/export/src/main.rs blocks_of`): rebuilds from those fields block for block; an old export is kept in its order and a block of skipped copies whose miner it does not name is refused instead of guessed. `tools/prove-fixtures/complete-export.mjs` completes an old export with the mergeset from `igneum_getSegment` (which names every skipped copy's block) for the fixtures. Fixtures `proving/fixtures/block-72803-skipped-copies.json` and `block-72854-empty-block-first.json`, each with `<name>.node-plan.json` beside it (the node's `igneum_getShardPlan`); `export/tests/fixtures.rs check_against_node_plan` asserts the cut's links, roots, gas, pgas and counts against the node's shard by shard, which the exporter-versus-core checks could not see. Known-failed shown: the old 72854 fixture under that check fails on "links (block index, block gas and pgas, tx accumulator)"; known-good: both new fixtures match the node's link_out exactly. Three unit tests on `blocks_of` (the 0.3.9 shape with an empty block, a skipped copy between two executed transactions and a block of skipped copies only; a count mismatch refused; the old shape kept in order and the skipped-only block refused). `cargo test --release -p igneum-prove-export`: 3 + 2 tests pass over 9 fixtures. The guest is untouched (no change under `core/`). The node side needs the 0.3.9 build and rollout; until every prover exports from a 0.3.9 node, a shard whose transaction block follows an empty block, or whose copies were all skipped, fails the veto.
Also noted: the collect job uploads the first 256 KiB of a log file, so a tail needs `--command "powershell -NoProfile -Command Get-Content -Tail 500 logs\app-....log"`.
Commands: `tools/lock/with-lock.sh run node tools/txgen/run.mjs --duration 1200 --rate 2 --wallets 16 --fund 2 --cap 40 --summary <file>`; `node tools/txgen/proving-watch.mjs watch --interval 30 --duration 1560 --out <jsonl>`; `node tools/txgen/proving-watch.mjs report --summary <summary.json> --pc2-log <collected app log>`; `node tools/prove-fixtures/complete-export.mjs seq.json out.json 72803,72854`; `igneum-prove-export out.json 72803 proving/fixtures/block-72803-skipped-copies.json`.

View file

@ -39,13 +39,13 @@ Versions in the table: `igneum-pow` is the Rust crate at `igneum-pow/Cargo.toml`
| 12 | The difficulty rule recovers from a hashrate step within minutes, where Kaspa's sampled rule never settles. A step inside an epoch set the rule oscillating on the live devnet on 4 October 2026; rule v2 removes it in the simulator and on a test network and is built but not yet rolled out | Spec 2.3; litepaper Speed (implied); bench page | tested by the team | repo `e9328c6`, `abb5a5d` (attacks), `67bf226` (rule v2); fork `difficulty` branch (timestamp fix) and `devnet-v4` `a21ff239` (`difficulty_v2_activation_daa`, `REF_WINDOW_V2 = 600`); `sim/difficulty/sim.py --live` | The live record `sim/difficulty/records/live-2026-10-04.csv` (8,090 headers, `pull_live.py`) and the hash-rate record beside it; `sim/difficulty/sim.py` on the synthetic set and the DAG replay; `sim/difficulty/attacks/attacks.py`; `sim/difficulty/testnet_v2.py` (3 nodes, activation at DAA 900); `cargo test --release -p kaspa-consensus --lib difficulty` (15 pass); bench-log "difficulty controller", "difficulty rule under attack", "timestamp attack fixed", "difficulty rule v2" | Live devnet v4, 4 October 2026 (UTC): a second RTX 5090 joining 7 minutes into an epoch (about 152 to 280 MH/s) hardened the difficulty 70M to 144M in 90 s and then swung by about a third for 40 minutes around the true level of 139M while the epoch-long reference lane carried the join; that card leaving for 4 minutes eased 116M to 67M and back to 106M; the epoch boundary with both PCs restarting took 152M to 77M in 3 minutes, after which the rule held within 1.3% per minute with no flips. Cause: the reference lane covered the whole epoch, so a mid-epoch step polluted it for the hour and the 25% trigger flipped on the short lane's noise. The DAG replay reproduces the record (std of log difficulty 0.115 against 0.134, 4.3 peaks against 4). Rule v2 (reference window 600 DAA) on the replay: std 0.026, 0 flips, mean 142.6M against 139M true; on a 3-node test network the v2 nodes eased a leave with no peak and held a rejoin within 3% after 60 s, and a node without the activation height forked off at it as designed. Rule v2 rolled onto the 12-node cloud network on 4 October (all nodes crossed the height on one chain; a hash-rate step then settled in 160 to 270 s with no swing) and activates on the devnet at DAA 33,000 the same evening. Timestamp forging (ledger M23) fixed the same day: a 50% forger drifts the rate under 1.1% where the 3 October rule gave it a 9.9x difficulty. Simulator, settled seconds: x50 step 62 to 66 (Kaspa 1,542), /50 step 657 to 753 (Kaspa 12,296). Apple M5 Max under load 7 to 442; the DAG model is fitted on one scale; the pool hopper's 0.7-point excess over Kaspa's rule stays open | none yet |
| 13 | Every node executes the ordered transactions natively and reaches the same state root | Litepaper Proving ("Every node executes ... natively"), Building ("runs on Igneum unchanged") | tested by the team | repo `f5f8c80`, `8dae48b`; fork `devnet-v4` `dc749905`; revm 43.0.3 | `node tools/evm-smoke/smoke.mjs` against a 3-node `igneumd`; `igneum-exec-diff seq.json`; bench-log "execution layer devnet v3" and "devnet-v4 integration" | Simnet, 3 October 2026: 87 of 87 viem checks, state roots identical on 3 nodes at four heights, 57 executed and 19 skipped transactions agree with plain revm, 0 mismatches. Merged node on real proof of work, 4 October 2026: 84 of 85 checks (the miss needs parallel blocks the network did not produce in 36 s), 59 transfers in 10 chain blocks, state roots identical on 3 nodes, `igneum-exec-diff` 0 mismatches over 59 transactions; the live devnet v4 runs this execution layer. Apple M5 Max. The prover is a stub; state is rebuilt from genesis at start; no EVM transaction relay between nodes | none yet |
| 14 | Ethereum bytecode runs unchanged, with the documented differences of spec 7.1 | Homepage Build card; litepaper Building | tested by the team | as row 13; fixes `F-exec-A`, `F-exec-B` (spec 7.5) | `tools/evm-smoke/smoke.mjs`: deploy via viem, `increment`, `hashLoop`, `eth_estimateGas`, `eth_getLogs`; `tools/exec-attacks` scenarios 1 and 3; bench-log "execution layer attack fixes" | Deployment, calls, reverts, logs and gas estimates behave as viem expects; chain id 4463; the prototype pgas table gives 0.0095 to 0.028 pgas per gas, below the design's band before calibration, 3 October 2026. 4 October 2026: a transaction that would cross the block's proving budget is refused by the mempool and, if forced in, aborted and charged with its nonce advanced (25 of 25 checks; 30 of 30 malformed cases). Apple M5 Max. The `Prover` precompile, proof records and the shard planner are not in the node | none yet |
| 15 | Every block is proven, with the proof landing within about a minute at launch | Homepage stats ("~60 s to a proof"); litepaper Proving; roadmap phase 3 gate | implemented | repo `d7e1f89` (GPU proof), `e01a3cc`, `292e800`, `eedd136` (`proving/igneum-prove`: shard cutter, MPT witnesses, shard and aggregator guests); SP1 6.8.1; spec 7.2, 7.6 | `proving/windows-wsl2` (SETUP-PROVER, PROVE-BLOCK) on the RTX 5090; `igneum-prove-host --mode block` on `proving/fixtures/`; bench-log "proving v0 on the RTX 5090" and "proving: devnet v4 shards" | First GPU proof of an Igneum block, 4 October 2026, RTX 5090 (WSL2, SP1 cuda, mining paused): fixture `block-78-increment` (2 transactions), core proof 1.4 s (7.3 MB, verify 0.221 s), compressed proof 2.7 s (1.27 MB, verify 0.038 s), post-state and receipts roots identical to the node's; 15.7x and 20.6x faster than a loaded M5 Max CPU. The same day on that CPU (load 38 to 47): a three-shard block proved shard by shard and aggregated by recursion, 19 min (1,139 s) end to end, 245 to 337 s per compressed shard proof, every proof verified. What is not there: no proof is produced, carried or checked on the chain (the devnet prover is a stub that signs claims), the proving pool pays nobody (row 21), the block proven is far below one shard, and the 60-second figure remains a design target; the pass mark is the standard in `docs/benchmarks/proving-e2e.md`. Second RTX 5090 run, 4 October 2026 evening (job run-20261004-173115): a full shard at the provisional S_p (6.75 M pgas, 60.8 M cycles) executed in 1.63 s, core proof 8.3 s (18.1 MB), compressed proof 10.9 s (1.27 MB, verify 0.040 s); a two-shard block (13.5 M pgas) proved shard by shard (11.7 s and 10.0 s) and aggregated in 2.2 s, 24 s of GPU stages end to end, every proof verified, six tampered witnesses rejected. The two host defects (an abort after the upload, an idle wait that turned out to be an unbuffered 18 MB proof save through the WSL2 file bridge, 24 minutes) are fixed (ledger P20) | none yet |
| 15 | Every block is proven, with the proof landing within about a minute at launch | Homepage stats ("~60 s to a proof"); litepaper Proving; roadmap phase 3 gate | implemented | repo `d7e1f89` (GPU proof), `e01a3cc`, `292e800`, `eedd136` (`proving/igneum-prove`: shard cutter, MPT witnesses, shard and aggregator guests); SP1 6.8.1; spec 7.2, 7.6 | `proving/windows-wsl2` (SETUP-PROVER, PROVE-BLOCK) on the RTX 5090; `igneum-prove-host --mode block` on `proving/fixtures/`; bench-log "proving v0 on the RTX 5090" and "proving: devnet v4 shards" | First GPU proof of an Igneum block, 4 October 2026, RTX 5090 (WSL2, SP1 cuda, mining paused): fixture `block-78-increment` (2 transactions), core proof 1.4 s (7.3 MB, verify 0.221 s), compressed proof 2.7 s (1.27 MB, verify 0.038 s), post-state and receipts roots identical to the node's; 15.7x and 20.6x faster than a loaded M5 Max CPU. The same day on that CPU (load 38 to 47): a three-shard block proved shard by shard and aggregated by recursion, 19 min (1,139 s) end to end, 245 to 337 s per compressed shard proof, every proof verified. What is not there: no proof is produced, carried or checked on the chain (the devnet prover is a stub that signs claims), the proving pool pays nobody (row 21), the block proven is far below one shard, and the 60-second figure remains a design target; the pass mark is the standard in `docs/benchmarks/proving-e2e.md`. Second RTX 5090 run, 4 October 2026 evening (job run-20261004-173115): a full shard at the provisional S_p (6.75 M pgas, 60.8 M cycles) executed in 1.63 s, core proof 8.3 s (18.1 MB), compressed proof 10.9 s (1.27 MB, verify 0.040 s); a two-shard block (13.5 M pgas) proved shard by shard (11.7 s and 10.0 s) and aggregated in 2.2 s, 24 s of GPU stages end to end, every proof verified, six tampered witnesses rejected. The two host defects (an abort after the upload, an idle wait that turned out to be an unbuffered 18 MB proof save through the WSL2 file bridge, 24 minutes) are fixed (ledger P20) 5 October 2026, live devnet with real transactions (bench-log "real transactions, the first non-empty shard proven and paid"): block 72704 shard 0, 29 transfers, 5,800 pgas, proven on PC 2 in 34 s, verified on the Mac in 0.297 s and paid 1.7623 IGN, 53 s after the chain block executed; of about 1,400 blocks in the 20-minute window 36 were proven (the one prover takes the newest shard assigned to it), so "every block" is not yet true; a second content shard (72803, all copies skipped) failed the native-execution veto on the exporter's block structure, fixed with fixtures the same day, the node side pending the 0.3.9 rollout | none yet |
| 16 | A 12 GB card proves one shard in about 20 s | Litepaper Proving ("The proving budget"); roadmap gate 2 | designed | spec 5.1 (Target), 7.6 (`S_p` provisional, 7,500,000 pgas = `B_p` / 4) | `PROVE-SHARD.bat` on the RTX 5090 (pending); the end-to-end standard in `docs/benchmarks/proving-e2e.md`; bench-log "proving: devnet v4 shards" | Measured on a 32 GB card, not yet on a 12 GB card. A shard at the provisional `S_p` is 60.8 M SP1 cycles on the prototype pgas table (9 cycles per pgas, 44 per EVM gas; the modexp entry about 100x its SP1 cost); on an RTX 5090 (4 October 2026 evening, job run-20261004-173115) it executed in 1.63 s and its compressed proof took 10.9 s, verified in 0.040 s, so the 32 GB card is inside the 20 s target with margin. Whether a 12 GB card proves it at all, and in what time, is the next measurement (an RTX 3060 and an RTX 5060 Ti 16 GB are on order). A per-shard time can be met by shrinking the shard, so the project does not use it as a pass mark | none yet |
| 17 | The chip resistance target: a chip gains under 2x over a GPU | Litepaper Mining, "What Igneum does not claim"; homepage "no chip can be built for it" | designed | spec 0.2 (Target); O-1.17 | Public benchmark with a leaderboard by card model and a standing bounty, January 2027 (O-1.17); the on-die-SRAM test on the RTX 5090 (R3.5) | A target, not a measurement. Review round 3 priced a recompute chip with the 256 MiB cache on die at about 2.4x, approximate, before the usual chip-versus-GPU integer gain; the design answer (cache larger than any die) is open (spec 1.16) | none yet |
| 18 | The chip resistance measurements: the program is random-access bound, not bandwidth bound, and sits beyond a card's on-chip cache | Litepaper Mining ("bound by memory bandwidth", to be corrected), vs RandomX "Measured so far" | tested by the team | repo `aba248d`, `f2a1a64`, `4b95c5e` | RTX 5090 dataset sweep 4 MiB to 1 GiB with `proto-cuda/host.cu`; bench-log "RTX 5090 first run" and "dataset sweep" | At 1 GiB: 228.1 Mhash/s, 23.7 G random loads/s, 94.9 GB/s useful against a 1,638 GB/s dataset fill; inside the 96 MiB L2 (4 and 64 MiB) 1,340 to 1,353 Mhash/s, about 5.8x faster; 104 against 128 loads per hash gives 228 against 185 Mhash/s, proportional. 3 October 2026, RTX 5090, Windows, CUDA 12.8, version 1 programs. Prototype dataset 1 GiB against 2 GB at genesis; a pure random-read microbenchmark (R3 chip designer, attack 2) has not run; the sweep has not been repeated on version 2 | none yet |
| 19 | The lottery hash is sound as a hash: uniform output, deterministic, no out-of-bounds read, fuzzed | Litepaper vs RandomX ("Every number above is measured and logged") | tested by the team | repo `c52307e`, `58a5a63`, `b27da39`; `proto-metal/TESTS.md` | `proto-metal/igneum-bench --fuzz --edge --stats --determinism --memcheck`; `--fuzz 2000` on the version 2 generator; `igneum-census`; bench-log "hardening tests", the re-run on the memory-hard dataset, "generator version 2 adopted" | Version 1: 10,200 random programs, 1,305,600 hashes, 0 mismatches; 14 of 14 edge cases; bit frequency within 2.90 sigma, avalanche mean 31.99 to 32.04 of 32; deterministic fingerprint across 5 runs; every dataset read masked, 3 October 2026. Version 2, 4 October 2026: 2,000 random programs through the Metal cross-check, 8,000 warps, 0 mismatches, 128 loads per hash on every program; 20,000-program census, 5.2% rejected (4.1% static, 1.1% dynamic). Apple M5 Max. Statistics are not a security proof; the edge, stats and memcheck sections were not re-run on version 2 (they do not depend on the generator); the seed derivation review (O-1.4) is open; the fuzz set has run on Metal and the CPU only | none yet |
| 20 | No premine, no pre-sale, no allocation: every coin is minted by the schedule and every coin goes to the block producer (80%) and the proving pool (20%) | Homepage stats and Economics tiles; litepaper Supply, Economics | implemented | repo `6ac80a3`; fork "igneum-node devnet v0"; `consensus/core/src/igneum.rs`, `coinbase.rs` | `cargo test -p kaspa-consensus-core igneum` (8 pass: subsidy table, ramp, split, cap) and `cargo test -p kaspa-consensus coinbase` (8 pass); `igneum-miner inspect 40`; bench-log "igneum-node devnet v0" | Coinbases on the devnet: 80/20 exact on 39 of 39 single-payee blocks, the 20% to the `igneum-proving-pool-v0` output; the per-second schedule sums to under the 4,000,000,000 cap by less than 100 coins; 3,168,808,781 units per DAA second in years 0 to 2, halving at 63,115,200 DAA s. 3 October 2026, Apple M5 Max. The devnet genesis carries no allocation; the mainnet genesis does not exist yet, so the claim is about the code and the stated rule, not a launch that has happened | none yet |
| 21 | The proving pool's 20% reaches shard provers and aggregators | Litepaper Economics; homepage "20% provers" | designed | spec 5.3; `proving/igneum-prove` carries the prover's payout address in every shard proof (ledger P12) | None. The pool output exists (row 20); the payout from it against proof records is unwritten | The escrow accumulated on the simnet (92.55 IGN at the end of the v3 run) and nothing can draw it. Rule decided: per block, divided among shards by consensus proving cost, sortition to 8 provers for 10 s then open (spec 7.2). The economy model of 4 October 2026 (`sim/economy`, 1,000 operators, 30 days) kept every block proven within 60 s under six stress scenarios; a model, not hardware | none yet |
| 21 | The proving pool's 20% reaches shard provers and aggregators | Litepaper Economics; homepage "20% provers" | tested by the team | spec 5.3; `proving/igneum-prove` carries the prover's payout address in every shard proof (ledger P12) | None. The pool output exists (row 20); the payout from it against proof records is unwritten. Since 5 October 2026: the payout rule is live on the devnet (`proving.rs shard_payouts`, the carrying segment pays the first valid record per shard its part of the segment's pool credit) | The escrow accumulated on the simnet (92.55 IGN at the end of the v3 run) and nothing can draw it. Rule decided: per block, divided among shards by consensus proving cost, sortition to 8 provers for 10 s then open (spec 7.2). The economy model of 4 October 2026 (`sim/economy`, 1,000 operators, 30 days) kept every block proven within 60 s under six stress scenarios; a model, not hardware Live devnet, 5 October 2026: 388 shards paid by 16:02 UTC, 446.13 IGN from the pool to PC 2's payout address, 0.8813 IGN per mergeset block of the proven segment (bench-log entries of 5 October: "the first shards proven, verified and paid" and "real transactions, the first non-empty shard proven and paid") | none yet |
| 22 | The base fee is burned in full and the priority fee splits 80% to the miner and provers, 20% to the apps whose code ran | Homepage Economics caption and Build card; litepaper "Where fees go" | tested by the team | repo `f5f8c80`; fork worktree `vendor/igneum-node-exec` | `tools/evm-smoke/smoke.mjs` receipt checks; bench-log "execution layer devnet v3" | Transfer receipt: `burnedProvingFee` 200 gwei, `minerTip` 16,800 gwei (80%), unregistered developer share 4,200 gwei burned; contract call: 80% to the miner, 20% credited to the payee the constructor registered, balance delta equal. 3 October 2026, Apple M5 Max simnet. The provers' part of the 80% is not split out (no provers exist); the base fee stayed at the 1 gwei floor throughout | none yet |
| 23 | No fee to any team, foundation or fund; 0 admin keys in consensus | Homepage Economics tiles and caption; litepaper "No fund, no foundation" and Governance | designed | spec 5.5, 5.6 (decided 3 October 2026); spec 08 | Reading: no coinbase output, fee route or consensus key in the fork names any party (`coinbase.rs`, `docs/fork-divergence.md`) | The emission code has two outputs (row 20) and the fee code has three routes (row 22), none to a team. The 1% fee of the official client is a client setting, not a protocol rule, and is not implemented. The release key of spec 08 signs client updates (the Igneum Miner app's over-the-air manifest since 4 October 2026, Ed25519) and holds no consensus power; its custody policy is open (O-8.1) | none yet |
| 24 | External proving jobs pay 90% to the provers who delivered and burn 10%, once settled in IGN | Homepage "IGN burned from jobs, phase two"; litepaper Proving and Economics | designed | spec 5.4 | None. Needs the proof bridge (spec 7.3, phase two) and the settlement switch (O-5.2) | At launch jobs are paid on the customer's chain in the customer's currency and nothing is burned (ledger P10). No job market code exists | none yet |
@ -60,9 +60,9 @@ Versions in the table: `igneum-pow` is the Rust crate at `igneum-pow/Cargo.toml`
| Status | Rows |
|---|---|
| designed | 6 (rows 16, 17, 21, 23, 24, 26) |
| designed | 5 (rows 16, 17, 23, 24, 26) |
| implemented | 3 (rows 2, 15, 20) |
| tested by the team | 21 (rows 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 18, 19, 22, 25, 27, 28, 29, 30) |
| tested by the team | 22 (rows 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 18, 19, 21, 22, 25, 27, 28, 29, 30) |
| reproduced externally | 0 |
| reviewed independently | 0 |
@ -81,6 +81,13 @@ Versions in the table: `igneum-pow` is the Rust crate at `igneum-pow/Cargo.toml`
| 26 | test-network certificate | live devnet certificate | the first live lock |
| 29, 30 | new | tested by the team | the cloud network's propagation run; the one-click app on PC 2 |
## What moved on 5 October 2026
| Row | Before | After | Why |
|---|---|---|---|
| 15 | implemented | implemented, with a live result | the first non-empty shard (block 72704, 29 transfers) proven, verified and paid on the devnet; not every block is proven yet |
| 21 | designed | tested by the team | 388 shards paid from the pool on the live devnet, the rule in `proving.rs`, the numbers in the bench log |
## What would move a row
| From | To | What it takes |

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
{
"daaScore": "0x1b7ad",
"eligibleKeys": 21,
"epochSeed": "0x621ae983d2d87639ae779431756345cd732e6b916918538eee6aab45c883a8e3",
"exclusiveUntilDaa": "0x1b7b7",
"hash": "0x97b9155639e779d0670bac3c19a845d995c64d72d06383211634d6985f181b52",
"number": "0x11c63",
"poolCreditWei": "0x187714e1fdf92800",
"preStateRoot": "0x12fa5864665172f29e14aea7228a8c9b992c33c49d17482f0a8b0544b707aee7",
"shardBudget": "0x7270e0",
"shards": [
{
"assignees": [
"0xab8a9b213113fb4660bdb2911f9fb0c22396a1ec4fab64a57245bcd44e237f40",
"0x070176b499f8d5e225e705140736856cdb40ae540c3313dab33da2741fda1ab2",
"0xfe40e51063fec331d39ebe2e323c2ade54c2f2d1601bd9847bca0ac40ec5ae4f",
"0xb7e3c337efcd892077bf46a7758d179268e433b83c1ecb6623e368af471c96fb",
"0xa72f7b7c4725dfc18df87752598932a0ed40bbef839691b06a4c88c5527315c5",
"0xdd375afb36e6baf839debb040ef69a8f6ca488234279aedc97956dfe0e914f5c",
"0x9915d26349e9ba34d42667028f1d21eb92007166d35fe79dfe0e6c8bec05d4b7",
"0xc41e4cc2bef6aa1d197ba462d1889351d0bbf5c09c4b5078ed6b475a4177957f"
],
"executed": 0,
"gas": "0x0",
"index": 0,
"linkIn": "0x38046159e1bf364d16df7645adc5a18f254dd70ffab279ed30785b727b52a3d5",
"linkOut": "0x111a55acfb56141ed917bb50d0057675abaa13183c2074652fc977dad686cbe2",
"overBudget": false,
"paid": null,
"pgas": "0x578",
"postRoot": "0x662d20d61c86eecab3c27aeb691dbd1ca613eac14517582d528a3be734a56d2b",
"preRoot": "0x12fa5864665172f29e14aea7228a8c9b992c33c49d17482f0a8b0544b707aee7",
"receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
"skipped": 7,
"txEnd": 7,
"txStart": 0
}
],
"stateRoot": "0x662d20d61c86eecab3c27aeb691dbd1ca613eac14517582d528a3be734a56d2b",
"windowBlocks": 7196
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
{
"daaScore": "0x1b7f0",
"eligibleKeys": 21,
"epochSeed": "0x621ae983d2d87639ae779431756345cd732e6b916918538eee6aab45c883a8e3",
"exclusiveUntilDaa": "0x1b7fa",
"hash": "0x6db7370641057e4f8e3ab2a848cf5f031f77abf71c4f87dd9188c41f26cfe4dc",
"number": "0x11c96",
"poolCreditWei": "0x18782113179fe800",
"preStateRoot": "0x74e01995f4ecd8da948a49bec9b8a69ebe587c47b90f517d67f993ba1554e60f",
"shardBudget": "0x7270e0",
"shards": [
{
"assignees": [
"0xc41e4cc2bef6aa1d197ba462d1889351d0bbf5c09c4b5078ed6b475a4177957f",
"0xdd375afb36e6baf839debb040ef69a8f6ca488234279aedc97956dfe0e914f5c",
"0x00cec3ae98cb74b654edc319864f23f35155580d458dde1dd1a4e40f991b1616",
"0x744a9ce5c6e8f46c5e19a8a74ffbd952a082d4d34eaea49a007d292c44936ada",
"0x6dfe55b3ed4b86af55b13960251ec667ad9e1680283ce84ed0f476c31cea39f1",
"0xa72f7b7c4725dfc18df87752598932a0ed40bbef839691b06a4c88c5527315c5",
"0x9915d26349e9ba34d42667028f1d21eb92007166d35fe79dfe0e6c8bec05d4b7",
"0xc5efca818b46d913443fee8a5edd7ca96c05a3d113f21962561f4ca3cc92c008"
],
"executed": 205,
"gas": "0x41b068",
"index": 0,
"linkIn": "0x38046159e1bf364d16df7645adc5a18f254dd70ffab279ed30785b727b52a3d5",
"linkOut": "0x85f27dc1f46937765c26a05f1379072cbc51b378d5848375ba34281fbcb56425",
"overBudget": false,
"paid": null,
"pgas": "0xa028",
"postRoot": "0x4cfe481561fad63e6908afadc179b642c5b796aeebb8a8ae0a708e99486be6d5",
"preRoot": "0x74e01995f4ecd8da948a49bec9b8a69ebe587c47b90f517d67f993ba1554e60f",
"receiptsRoot": "0x0ff9926cccd09d144e3a394f3c1009cd00ff59eec62919e587809a5d2dd629c2",
"skipped": 0,
"txEnd": 205,
"txStart": 0
}
],
"stateRoot": "0x4cfe481561fad63e6908afadc179b642c5b796aeebb8a8ae0a708e99486be6d5",
"windowBlocks": 7196
}

View file

@ -8,7 +8,7 @@
//! and checks every shard's witness, and writes the fixture with the expected values per shard and per block.
use alloy_primitives::{keccak256, Address, Bytes, B256, U256};
use anyhow::{bail, Context, Result};
use anyhow::{ensure, bail, Context, Result};
use igneum_prove_core::config::{DEVELOPER_REGISTRY_ADDRESS, SHARD_PROVING_GAS_BUDGET};
use igneum_prove_core::shard::build_shards;
use igneum_prove_core::{execute_block, AccountFixture, BlockFixture, Expected, Fixture, FixtureEnv, IgneumDb, IncludingBlock, Plan, ShardExpected};
@ -27,29 +27,69 @@ fn bytes(v: &Value) -> Result<Vec<u8>> {
Ok(hex::decode(v.as_str().context("hex")?.trim_start_matches("0x"))?)
}
/// Rebuilds the segment's including blocks from the export, which lists executed transactions in sequence
/// order with their including miner and skipped copies after them. Consecutive transactions with the same
/// including miner form one block; the blue flag is not in the export and is irrelevant to execution (rewards
/// are carried separately), so every reconstructed block is marked blue.
/// Rebuilds the segment's including blocks from the export.
///
/// A 0.3.9 export names the mergeset ("blocks": hash, miner, blue, txCount, in order, empty blocks included) and
/// gives every transaction entry its "block" index and "position" in the segment's body order, executed and
/// skipped copies alike; the blocks are rebuilt exactly, so the links and the tx accumulator are the node's.
///
/// An older export lists the entries per including block as executed-then-skipped, with no position, no empty
/// blocks and no miner on a skipped copy. Until 5 October 2026 this function sorted the entries by "sequence"
/// (which moved every skipped copy behind every executed one, out of its block), merged consecutive blocks of one
/// miner, dropped the empty blocks (the node counts them in `block_index`) and gave a block of skipped copies only
/// the previous block's miner or the zero address; devnet block 72803 (seven skipped copies, no executed
/// transaction) and 72854 (an empty block before the 205 transactions) both gave statements the node's
/// native-execution veto refused. The old shape is now rebuilt in export order with no sorting, and a block of
/// skipped copies whose miner the export does not name is refused instead of guessed; the two other faults need
/// the 0.3.9 fields and cannot be repaired from the old shape.
fn blocks_of(seg: &Value) -> Result<Vec<IncludingBlock>> {
let mut items: Vec<(&Value, usize)> = seg["txs"].as_array().context("txs")?.iter().enumerate().map(|(i, t)| (t, i)).collect();
items.sort_by_key(|(t, i)| (t["sequence"].as_u64().map(|s| s as usize).unwrap_or(usize::MAX), *i));
let mut blocks: Vec<IncludingBlock> = Vec::new();
for (t, _) in items {
let number = seg["number"].as_u64().unwrap_or(0);
let txs = seg["txs"].as_array().context("txs")?;
if let Some(blocks) = seg["blocks"].as_array() {
let mut out: Vec<IncludingBlock> = blocks
.iter()
.map(|b| Ok(IncludingBlock { miner: addr(&b["miner"])?, blue: b["blue"].as_bool().context("blocks[].blue")?, txs: Vec::new() }))
.collect::<Result<_>>()?;
let mut items: Vec<(usize, usize, &Value)> = txs
.iter()
.map(|t| Ok((t["block"].as_u64().context("txs[].block")? as usize, t["position"].as_u64().context("txs[].position")? as usize, t)))
.collect::<Result<_>>()?;
items.sort_by_key(|(_, position, _)| *position);
for (block, _, t) in items {
let raw = bytes(&t["raw"])?;
ensure!(!raw.is_empty(), "segment {number}: an entry with no raw bytes");
out.get_mut(block).with_context(|| format!("segment {number}: block index {block} is not in blocks"))?.txs.push(Bytes::from(raw));
}
for (i, (b, o)) in blocks.iter().zip(&out).enumerate() {
if let Some(n) = b["txCount"].as_u64() {
ensure!(n as usize == o.txs.len(), "segment {number}: block {i} has {} entries, txCount says {n}", o.txs.len());
}
}
return Ok(out);
}
let mut out: Vec<IncludingBlock> = Vec::new();
for t in txs {
let raw = bytes(&t["raw"])?;
if raw.is_empty() {
continue;
}
let miner = match t["miner"].as_str() {
Some(m) => m.parse()?,
None => blocks.last().map(|b| b.miner).unwrap_or(Address::ZERO),
};
match blocks.last_mut() {
Some(b) if b.miner == miner => b.txs.push(Bytes::from(raw)),
_ => blocks.push(IncludingBlock { miner, blue: true, txs: vec![Bytes::from(raw)] }),
match t["miner"].as_str() {
Some(m) => {
let miner: Address = m.parse()?;
match out.last_mut() {
Some(b) if b.miner == miner => b.txs.push(Bytes::from(raw)),
_ => out.push(IncludingBlock { miner, blue: true, txs: vec![Bytes::from(raw)] }),
}
}
None => match out.last_mut() {
Some(b) => b.txs.push(Bytes::from(raw)),
None => bail!(
"segment {number}: a block of skipped copies only, and this export does not name its miner (a node before 0.3.9); export the segment again from a 0.3.9 node"
),
},
}
}
Ok(blocks)
Ok(out)
}
fn fixture_of(export: &Value, segments: &[Value], n: usize, hashes: &[(u64, B256)], db: &IgneumDb) -> Result<BlockFixture> {
@ -197,3 +237,71 @@ fn main() -> Result<()> {
println!("wrote {out_path} ({} bytes, input commitment {})", json.len(), keccak256(json.as_bytes()));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const M1: &str = "0x1111111111111111111111111111111111111111";
const M2: &str = "0x2222222222222222222222222222222222222222";
/// The 0.3.9 shape: the mergeset with an empty block first, two consecutive blocks of one miner, a skipped
/// copy between two executed transactions of one block and a block of skipped copies only. Rebuilt exactly.
#[test]
fn a_0_3_9_export_is_rebuilt_block_for_block_in_body_order() {
let seg = json!({
"number": 7,
"blocks": [
{ "hash": "0x0a", "miner": M1, "blue": true, "txCount": 0 },
{ "hash": "0x0b", "miner": M2, "blue": true, "txCount": 3 },
{ "hash": "0x0c", "miner": M2, "blue": false, "txCount": 1 },
{ "hash": "0x0d", "miner": M1, "blue": true, "txCount": 2 },
],
"txs": [
// executed first then skipped, as the old grouping would have it; the positions put them right
{ "raw": "0x01", "miner": M2, "sequence": 0, "skipped": null, "block": 1, "position": 0 },
{ "raw": "0x03", "miner": M2, "sequence": 1, "skipped": null, "block": 1, "position": 2 },
{ "raw": "0x02", "miner": M2, "sequence": null, "skipped": "NonceTooLow", "block": 1, "position": 1 },
{ "raw": "0x04", "miner": M2, "sequence": 2, "skipped": null, "block": 2, "position": 3 },
{ "raw": "0x05", "miner": M1, "sequence": null, "skipped": "NonceTooLow", "block": 3, "position": 4 },
{ "raw": "0x06", "miner": M1, "sequence": null, "skipped": "NonceTooLow", "block": 3, "position": 5 },
]
});
let blocks = blocks_of(&seg).unwrap();
let shape: Vec<(String, bool, Vec<u8>)> = blocks.iter().map(|b| (format!("{:?}", b.miner), b.blue, b.txs.iter().map(|t| t[0]).collect())).collect();
assert_eq!(
shape,
vec![
(M1.to_string(), true, vec![]),
(M2.to_string(), true, vec![1, 2, 3]),
(M2.to_string(), false, vec![4]),
(M1.to_string(), true, vec![5, 6]),
]
);
}
#[test]
fn a_0_3_9_export_whose_counts_disagree_is_refused() {
let seg = json!({ "number": 7, "blocks": [{ "hash": "0x0b", "miner": M2, "blue": true, "txCount": 2 }], "txs": [{ "raw": "0x01", "block": 0, "position": 0 }] });
let e = blocks_of(&seg).unwrap_err().to_string();
assert!(e.contains("txCount says 2"), "{e}");
}
/// The old shape keeps the export order (a skipped copy stays in the block it follows) and a block of
/// skipped copies only is refused, not given the zero address (devnet block 72803).
#[test]
fn an_old_export_keeps_its_order_and_refuses_a_block_of_skipped_copies_without_a_miner() {
let seg = json!({ "number": 7, "txs": [
{ "raw": "0x01", "miner": M2, "sequence": 0, "skipped": null },
{ "raw": "0x02", "miner": null, "sequence": null, "skipped": "NonceTooLow" },
{ "raw": "0x03", "miner": M1, "sequence": 1, "skipped": null },
] });
let blocks = blocks_of(&seg).unwrap();
let shape: Vec<Vec<u8>> = blocks.iter().map(|b| b.txs.iter().map(|t| t[0]).collect()).collect();
assert_eq!(shape, vec![vec![1, 2], vec![3]], "the skipped copy stays behind the executed transaction of its block");
let only_skipped = json!({ "number": 72803, "txs": [{ "raw": "0x02", "miner": null, "sequence": null, "skipped": "NonceTooLow" }] });
let e = blocks_of(&only_skipped).unwrap_err().to_string();
assert!(e.contains("segment 72803") && e.contains("0.3.9"), "{e}");
}
}

View file

@ -73,19 +73,55 @@ fn check(name: &str) {
}
}
/// `<fixture>.node-plan.json` beside a fixture is the node's `igneum_getShardPlan` for that block. Where it exists the
/// exporter's cut must give the node's statement fields shard by shard: the links (which carry the block index and the
/// tx accumulator, so the including-block structure), the roots, gas, pgas and the counts. The exporter-versus-core
/// checks above cannot see a wrong block structure (both sides read the same fixture); this can. Added 5 October 2026
/// for devnet blocks 72803 and 72854, whose proofs the node's native-execution veto refused.
fn check_against_node_plan(name: &str) -> bool {
let path = fixtures_dir().join(format!("{name}.node-plan.json"));
if !path.exists() {
return false;
}
let f = load(name);
let node: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let u64_of = |v: &serde_json::Value| -> u64 { u64::from_str_radix(v.as_str().unwrap().trim_start_matches("0x"), 16).unwrap() };
let b256_of = |v: &serde_json::Value| -> B256 { v.as_str().unwrap().parse().unwrap() };
let (_, _, shards) = build_shards(&f.block, f.plan.shard_budget, Address::ZERO);
let node_shards = node["shards"].as_array().unwrap();
assert_eq!(shards.len(), node_shards.len(), "{name}: shard count against the node");
assert_eq!(b256_of(&node["preStateRoot"]), f.expected.pre_state_root, "{name}: pre-state root against the node");
assert_eq!(b256_of(&node["stateRoot"]), f.expected.post_state_root, "{name}: post-state root against the node");
for (s, n) in shards.iter().zip(node_shards) {
let o = &s.output;
let tag = format!("{name} shard {} against the node", n["index"]);
assert_eq!((s.spec.tx_start as u64, s.spec.tx_end as u64), (n["txStart"].as_u64().unwrap(), n["txEnd"].as_u64().unwrap()), "{tag}: range");
assert_eq!((o.link_in, o.link_out), (b256_of(&n["linkIn"]), b256_of(&n["linkOut"])), "{tag}: links (block index, block gas and pgas, tx accumulator)");
assert_eq!((o.pre_root, o.post_root, o.receipts_root), (b256_of(&n["preRoot"]), b256_of(&n["postRoot"]), b256_of(&n["receiptsRoot"])), "{tag}: roots");
assert_eq!((o.gas_used, o.pgas_used), (u64_of(&n["gas"]), u64_of(&n["pgas"])), "{tag}: gas and pgas");
assert_eq!((o.executed as u64, o.skipped as u64), (n["executed"].as_u64().unwrap(), n["skipped"].as_u64().unwrap()), "{tag}: executed and skipped");
}
true
}
#[test]
fn every_fixture_reproduces() {
let mut names: Vec<String> = std::fs::read_dir(fixtures_dir())
.expect("proving/fixtures")
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".json"))
.filter(|n| n.ends_with(".json") && !n.ends_with(".node-plan.json"))
.collect();
names.sort();
assert!(names.len() >= 7, "fixtures present: {names:?}");
let mut against_node = 0;
for n in &names {
check(n);
if check_against_node_plan(n) {
against_node += 1;
}
}
assert!(against_node >= 2, "fixtures with a node plan beside them: {against_node} (72803 and 72854 expected)");
}
#[test]

View file

@ -0,0 +1,53 @@
#!/usr/bin/env node
// Completes an `igneum_exportSegments` file from a node before 0.3.9 with the block structure the 0.3.9 export carries
// (5 October 2026, after devnet block 72803 failed the native-execution veto): per segment "blocks" (the mergeset in
// order, empty blocks included: hash, miner, blue, txCount) and per transaction entry "block" (its index in that
// list) and "position" (its place in the segment's body order). The structure comes from `igneum_getSegment`, which
// names every skipped copy's including block; executed entries are placed by their "sequence" in the one block of
// the segment that executed them (a segment whose executed transactions span several blocks cannot be completed
// this way and is refused: re-export it from a 0.3.9 node). A block that mixes executed and skipped copies keeps
// the export's order (executed first), which is the body order only when no skipped copy precedes an executed one;
// such a segment is refused too. No dependencies.
//
// node tools/prove-fixtures/complete-export.mjs <seq.json> <out.json> <block>[,<block>...] [--rpc http://127.0.0.1:26800]
import { readFileSync, writeFileSync } from 'node:fs';
const [seqPath, outPath, list] = process.argv.slice(2);
const rpcUrl = process.argv.includes('--rpc') ? process.argv[process.argv.indexOf('--rpc') + 1] : 'http://127.0.0.1:26800';
if (!seqPath || !outPath || !list) { console.error('usage: complete-export.mjs <seq.json> <out.json> <block>[,<block>...] [--rpc url]'); process.exit(2); }
const rpc = async (method, params) => { const r = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); const j = await r.json(); if (j.error) throw new Error(j.error.message); return j.result; };
const seq = JSON.parse(readFileSync(seqPath, 'utf8'));
const segments = seq.result ? seq.result.segments : seq.segments;
for (const n of list.split(',').map(Number)) {
const seg = segments.find((s) => s.number === n);
if (!seg) throw new Error(`segment ${n} is not in ${seqPath}`);
const node = await rpc('igneum_getSegment', ['0x' + n.toString(16)]);
seg.blocks = node.mergeset.map((m) => ({ hash: m.hash, miner: m.miner, blue: m.blue, txCount: m.txCount }));
const skippedBlock = new Map((node.skipped || []).map((s) => [s.hash.toLowerCase(), s.includingBlock || s.block]));
const txBlocks = seg.blocks.map((b, i) => [i, b]).filter(([, b]) => b.txCount > 0);
const executedBlocks = new Set();
let position = 0;
const entries = seg.txs.slice();
// executed entries first in sequence order (the export already lists them so), then the skipped copies in list order
entries.sort((a, b) => (a.sequence ?? Infinity) - (b.sequence ?? Infinity));
for (const t of entries) {
if (t.skipped == null) {
const candidates = txBlocks.filter(([, b]) => b.miner.toLowerCase() === t.miner.toLowerCase());
if (candidates.length !== 1) throw new Error(`segment ${n}: executed ${t.hash} by ${t.miner}: ${candidates.length} blocks of that miner carry transactions; re-export from a 0.3.9 node`);
t.block = candidates[0][0]; executedBlocks.add(t.block);
} else {
const h = skippedBlock.get(t.hash.toLowerCase());
if (!h) throw new Error(`segment ${n}: skipped ${t.hash} has no including block in igneum_getSegment`);
t.block = seg.blocks.findIndex((b) => b.hash.toLowerCase() === h.toLowerCase());
if (t.block < 0) throw new Error(`segment ${n}: skipped ${t.hash}: block ${h} is not in the mergeset`);
if (executedBlocks.has(t.block)) throw new Error(`segment ${n}: block ${t.block} mixes executed and skipped copies; the body order is not in this export, re-export from a 0.3.9 node`);
t.miner = seg.blocks[t.block].miner; t.blue = seg.blocks[t.block].blue;
}
t.position = position++;
}
for (const [i, b] of seg.blocks.entries()) { const got = entries.filter((t) => t.block === i).length; if (got !== b.txCount) throw new Error(`segment ${n}: block ${i} has ${got} entries, txCount ${b.txCount}`); }
seg.txs = entries;
console.log(`segment ${n}: ${seg.blocks.length} blocks (${seg.blocks.map((b) => `${b.miner.slice(0, 6)}:${b.txCount}`).join(', ')}), ${entries.length} entries placed`);
}
// written as the bare export object (what igneum-prove-export reads), whether the input carried the JSON-RPC envelope or not
writeFileSync(outPath, JSON.stringify({ ...(seq.result || seq), segments }));
console.log(`wrote ${outPath}`);

1
tools/txgen/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

219
tools/txgen/package-lock.json generated Normal file
View file

@ -0,0 +1,219 @@
{
"name": "igneum-txgen",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "igneum-txgen",
"version": "0.1.0",
"dependencies": {
"viem": "^2.21.0"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
"integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.9.0",
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/abitype": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
"integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/wevm"
},
"peerDependencies": {
"typescript": ">=5.0.4",
"zod": "^3.22.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/isows": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
"integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/ox": {
"version": "0.14.45",
"resolved": "https://registry.npmjs.org/ox/-/ox-0.14.45.tgz",
"integrity": "sha512-jpsQ+p0JZh9mKCxxzxz0d5qFHZZg2/O9xW18IpEC/dNXXPVqmDJ0c//9RMz0CILkbSGPA42+cbU0fC/ghwJ03w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "^1.11.0",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "1.9.1",
"@noble/hashes": "^1.8.0",
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"abitype": "^1.2.3",
"eventemitter3": "5.0.1"
},
"peerDependencies": {
"typescript": ">=5.4.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/viem": {
"version": "2.57.2",
"resolved": "https://registry.npmjs.org/viem/-/viem-2.57.2.tgz",
"integrity": "sha512-ZbPvu+5fvRsbFh+OymDz0GyEySsSco8GWrEAiHM4ckhEY1vJTPy0tcLVkn0cs70dPiUOjLxkQBnfVyt9YkJ7cA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"dependencies": {
"@noble/curves": "1.9.1",
"@noble/hashes": "1.8.0",
"@scure/bip32": "1.7.0",
"@scure/bip39": "1.6.0",
"abitype": "1.2.3",
"isows": "1.0.7",
"ox": "0.14.45",
"ws": "8.21.0"
},
"peerDependencies": {
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

14
tools/txgen/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "igneum-txgen",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Igneum devnet transaction generator: funds generated wallets from the devnet dev-fee key and sends transfers between them at a steady rate (viem for EIP-1559 signing, the same library tools/evm-smoke and tools/exec-attacks use)",
"scripts": {
"run": "node run.mjs",
"watch": "node proving-watch.mjs"
},
"dependencies": {
"viem": "^2.21.0"
}
}

View file

@ -0,0 +1,197 @@
#!/usr/bin/env node
// The proving layer watched while tools/txgen/run.mjs fills blocks (5 October 2026). Two modes, no dependencies.
//
// node tools/txgen/proving-watch.mjs watch [--rpc http://127.0.0.1:26800] [--interval 30] [--duration 1500]
// [--node-log newest|<file>] [--live https://igneum.network/api/live] [--out <jsonl>]
// Every interval: igneum_getProvingStatus (paid shards, paid wei, pool), the live page's proving object, the Mac
// node log's proof-record lines since the start (accepted, verified, not verified, paid) and the live page's
// per-block shard cells with content. One line per sample, every sample appended to --out as JSON.
//
// node tools/txgen/proving-watch.mjs report --summary <txgen summary.json> [--rpc ...] [--node-log newest|<file>]
// [--pc2-log <collected app log>] [--from <ISO time>] [--to <ISO time>] [--json <file>]
// For every block the generator saw content in (and every block a proof record named in the window): the shard
// plan (pgas, gas, transactions per shard, paid), the node log's record (accepted, verify time, verdict, payout,
// carrying chain block) and PC 2's prover lines for that block (export, cut, prove, submit, with their times when
// the line carries them). Prints Markdown tables for docs/bench-log.md and a JSON when --json is given.
//
// The node log lines this reads (vendor/igneum-node-036/igneum/exec, 0.3.8):
// proof record for block N shard S by KEY (peer|local): accepted, verify Command(...)
// proof of block N shard S by KEY: verified in X s: RESULT verify: VERIFIED in Y s; ... proof B bytes at T
// proof of block N shard S by KEY: ... NOT VERIFIED ... (a rejection names the reason on the same line)
// chain block C: proof record for block N shard S by KEY paid W wei to ADDR
// PC 2's app log lines: "prover: block N shard S ..." (app/igneum-app, the proving job runner).
import { readFileSync, readdirSync, statSync, appendFileSync, writeFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const args = process.argv.slice(2);
const mode = args[0];
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : dflt; };
const RPC = opt('--rpc', 'http://127.0.0.1:26800');
const LIVE = opt('--live', 'https://igneum.network/api/live');
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ign = (w) => (Number(BigInt(w)) / 1e18).toFixed(6);
let id = 0;
async function rpc(method, params = []) {
const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), signal: AbortSignal.timeout(20000) });
const j = await r.json(); if (j.error) throw new Error(j.error.message); return j.result;
}
const live = () => fetch(LIVE, { signal: AbortSignal.timeout(20000) }).then((r) => r.json()).catch((e) => ({ error: e.message }));
function newestNodeLog() {
const dir = join(homedir(), 'Library', 'Logs', 'Igneum');
if (!existsSync(dir)) return null;
const files = readdirSync(dir).filter((f) => /^node-.*\.log$/.test(f)).map((f) => join(dir, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
return files[0] || null;
}
const nodeLogPath = () => { const v = opt('--node-log', 'newest'); return v === 'newest' ? newestNodeLog() : v; };
// a log line's timestamp "2026-10-05 16:24:13.035+01:00" as epoch ms
const lineTime = (l) => { const m = /^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d\.\d+)([+-]\d\d:\d\d)/.exec(l); return m ? Date.parse(`${m[1]}T${m[2]}${m[3]}`) : null; };
// parses every proof-record line into {kind, block, shard, key, t, ...}
function parseNodeLog(text) {
const out = [];
for (const l of text.split('\n')) {
let m;
if ((m = /proof record for block (\d+) shard (\d+) by ([0-9a-f]{64}) \((peer|local)\): accepted/.exec(l))) out.push({ kind: 'accepted', block: +m[1], shard: +m[2], key: m[3].slice(0, 8), origin: m[4], t: lineTime(l) });
else if ((m = /proof of block (\d+) shard (\d+) by ([0-9a-f]{64}): (?:verified in ([\d.]+) s: )?(.*)$/.exec(l))) {
const rest = m[5];
const verdict = /\bVERIFIED\b/.test(rest) && !/NOT VERIFIED/.test(rest) ? 'VERIFIED' : 'NOT VERIFIED';
const inner = /VERIFIED in ([\d.]+) s/.exec(rest);
const bytes = /proof (\d+) bytes/.exec(rest);
out.push({ kind: verdict === 'VERIFIED' ? 'verified' : 'rejected', block: +m[1], shard: +m[2], key: m[3].slice(0, 8), t: lineTime(l), verify_wall_s: m[4] ? +m[4] : null, verify_s: inner ? +inner[1] : null, bytes: bytes ? +bytes[1] : null, text: verdict === 'VERIFIED' ? '' : rest.slice(0, 300) });
} else if ((m = /chain block (\d+): proof record for block (\d+) shard (\d+) by ([0-9a-f]{64}) paid (\d+) wei to (0x[0-9a-fA-F]{40})/.exec(l))) out.push({ kind: 'paid', chain_block: +m[1], block: +m[2], shard: +m[3], key: m[4].slice(0, 8), wei: m[5], to: m[6], t: lineTime(l) });
else if (/\[igneum-exec\].*(proof|prov|shard|export).*(error|fail|panic|refus|reject)/i.test(l)) out.push({ kind: 'error', t: lineTime(l), text: l.replace(/^\S+ \S+ /, '').slice(0, 300) });
}
return out;
}
const counts = (recs) => recs.reduce((a, r) => { a[r.kind] = (a[r.kind] || 0) + 1; return a; }, {});
// ---- watch ----
async function watch() {
const INTERVAL = 1000 * +opt('--interval', 30), DURATION = 1000 * +opt('--duration', 1500);
const OUT = opt('--out', null);
const logFile = nodeLogPath();
const startAt = Date.now();
const startOffset = logFile ? statSync(logFile).size : 0;
const cells = new Map(); // block hash prefix -> the live page's cell with shards
log(`watching ${RPC}, live ${LIVE}, node log ${logFile || 'none'} (from byte ${startOffset}); every ${INTERVAL / 1000} s for ${DURATION / 1000} s`);
let last = null;
while (Date.now() - startAt < DURATION) {
const [ps, lv] = await Promise.all([rpc('igneum_getProvingStatus').catch((e) => ({ error: e.message })), live()]);
const tip = await rpc('eth_blockNumber').then((n) => Number(BigInt(n))).catch(() => null);
let recs = [];
if (logFile) { try { const t = readFileSync(logFile, 'utf8'); recs = parseNodeLog(t.slice(startOffset)); } catch { } }
for (const b of lv.blocks || []) if (b.shards?.length) cells.set(b.hash, b);
const c = counts(recs);
const sample = {
t: new Date().toISOString(), tip, paid_shards: ps.paidShards, paid_ign: ps.paidWei ? ign(ps.paidWei) : null, pool: ps.pool, tip_daa: ps.tipDaa ? Number(BigInt(ps.tipDaa)) : null,
live: lv.proving || lv.error || null, log_since_start: c, cells_with_shards: cells.size,
rejected: recs.filter((r) => r.kind === 'rejected').slice(-3), errors: recs.filter((r) => r.kind === 'error').slice(-3),
};
const d = last ? ` (+${(ps.paidShards ?? 0) - (last.paid_shards ?? 0)} paid, +${(c.accepted || 0) - (last.log_since_start.accepted || 0)} accepted, +${(c.verified || 0) - (last.log_since_start.verified || 0)} verified)` : '';
log(`tip ${tip}: paid shards ${ps.paidShards} (${sample.paid_ign} IGN), pool verified ${ps.pool?.verified} pending ${ps.pool?.pending} failed ${ps.pool?.failed}; log since start: accepted ${c.accepted || 0}, verified ${c.verified || 0}, rejected ${c.rejected || 0}, paid ${c.paid || 0}, errors ${c.error || 0}${d}; live: shards 10m ${lv.proving?.shards_proven_10m}, fully proven blocks 10m ${lv.proving?.blocks_fully_proven_10m}, lag ${lv.proving?.median_proof_lag_s} s, provers ${lv.proving?.provers_10m}; cells with shards seen ${cells.size}`);
for (const r of sample.rejected) log(` rejected: block ${r.block} shard ${r.shard} by ${r.key}: ${r.text}`);
for (const r of sample.errors) log(` error: ${r.text}`);
if (OUT) appendFileSync(OUT, JSON.stringify(sample) + '\n');
last = sample;
await sleep(INTERVAL);
}
if (OUT && cells.size) appendFileSync(OUT, JSON.stringify({ t: new Date().toISOString(), cells: [...cells.values()] }) + '\n');
log(`done; ${cells.size} live-page blocks showed shard cells`);
}
// ---- report ----
const hex = (v) => (v == null ? null : Number(BigInt(v)));
async function report() {
const summaryFile = opt('--summary', null);
if (!summaryFile) throw new Error('--summary <txgen summary.json> is required');
const s = JSON.parse(readFileSync(summaryFile, 'utf8'));
const from = Date.parse(opt('--from', s.started)), to = Date.parse(opt('--to', s.ended)) + 10 * 60 * 1000; // records land up to minutes after the last send
const logFile = nodeLogPath();
const recs = logFile ? parseNodeLog(readFileSync(logFile, 'utf8')).filter((r) => r.t == null || (r.t >= from && r.t <= to)) : [];
// PC 2's app log (collected with publish-jobs.sh --kind collect --glob "logs/app-*.log", read with tools/jobs.mjs <id> --all):
// <epoch> prover: block N shard S assigned to win-1ccfe586-1-1: export, cut, prove (CUDA), sign, submit
// <epoch> [proving] block N shard S proven and submitted in T s
// <epoch> [proving] block N shard S paid X IGN
// <epoch> prover: block N shard S: <stage>: failed ... (any line naming the block with "failed" or "error")
const pc2 = [];
if (opt('--pc2-log', null)) for (const l of readFileSync(opt('--pc2-log'), 'utf8').split('\n')) {
let m;
if ((m = /^(\d+) prover: block (\d+) shard (\d+) assigned to (\S+):/.exec(l))) pc2.push({ kind: 'assigned', t: +m[1] * 1000, block: +m[2], shard: +m[3], prover: m[4] });
else if ((m = /^(\d+) \[proving\] block (\d+) shard (\d+) proven and submitted in ([\d.]+) s/.exec(l))) pc2.push({ kind: 'proven', t: +m[1] * 1000, block: +m[2], shard: +m[3], prove_s: +m[4] });
else if ((m = /^(\d+) \[proving\] block (\d+) shard (\d+) paid ([\d.]+) IGN/.exec(l))) pc2.push({ kind: 'paid', t: +m[1] * 1000, block: +m[2], shard: +m[3], ign: +m[4] });
else if ((m = /^(\d+) .*block (\d+) shard (\d+).*(failed|error|panic|refused)/i.exec(l))) pc2.push({ kind: 'failed', t: +m[1] * 1000, block: +m[2], shard: +m[3], text: l.replace(/^\d+ /, '').slice(0, 300) });
}
const contentBlocks = Object.keys(s.blocks?.per_block || {}).map(Number);
const recordBlocks = [...new Set(recs.filter((r) => r.block).map((r) => r.block))];
const blocks = [...new Set([...contentBlocks, ...recordBlocks])].sort((a, b) => a - b);
const rows = [];
for (const n of blocks) {
let plan = null;
try { plan = await rpc('igneum_getShardPlan', ['0x' + n.toString(16)]); } catch (e) { plan = { error: e.message }; }
const shards = (plan?.shards || []).map((sh) => ({ index: sh.index, pgas: hex(sh.pgas), gas: hex(sh.gas), txs: (sh.txEnd ?? 0) - (sh.txStart ?? 0), executed: sh.executed, skipped: sh.skipped, paid: sh.paid, over_budget: sh.overBudget }));
const mine = recs.filter((r) => r.block === n);
const byShard = {};
for (const r of mine) {
const b = (byShard[r.shard] ||= { accepted: [], verified: [], rejected: [], paid: [] });
b[r.kind]?.push(r);
}
const pcLines = pc2.filter((e) => e.block === n);
rows.push({ block: n, txs_included_by_txgen: s.blocks?.per_block?.[n] ?? 0, plan_error: plan?.error || null, shards, records: byShard, pc2: pcLines });
}
// tables
const sum = { blocks: rows.length, content_blocks: rows.filter((r) => r.shards.some((x) => x.pgas > 0)).length, content_shards: 0, content_shards_paid: 0, content_shards_verified: 0, content_shards_rejected: 0, empty_shards_paid: 0, paid_ign_window: 0 };
console.log(`\n## Blocks with content in the window ${new Date(from).toISOString()} to ${new Date(to).toISOString()} (node log ${logFile || 'none'})\n`);
console.log('| Block | Txs (txgen) | Shard | Txs in shard | pgas | gas | Accepted (UTC) | Verify wall / inner | Verdict | Paid (IGN) at chain block | PC 2 prover lines |');
console.log('|---|---|---|---|---|---|---|---|---|---|---|');
for (const r of rows) {
if (r.plan_error) { console.log(`| ${r.block} | ${r.txs_included_by_txgen} | plan error: ${r.plan_error} | | | | | | | | |`); continue; }
for (const sh of r.shards) {
const rec = r.records[sh.index] || { accepted: [], verified: [], rejected: [], paid: [] };
const acc = rec.accepted[0]?.t ? new Date(rec.accepted[0].t).toISOString().slice(11, 19) : '';
const v = rec.verified[0], rj = rec.rejected[0], pd = rec.paid[0];
const verify = v ? `${v.verify_wall_s ?? '?'} s / ${v.verify_s ?? '?'} s` : rj ? `${rj.verify_wall_s ?? '?'} s` : '';
const verdict = v ? 'VERIFIED' : rj ? `NOT VERIFIED: ${rj.text.slice(0, 120)}` : rec.accepted.length ? 'no verdict logged' : (sh.pgas > 0 ? 'NO RECORD' : '');
const paid = pd ? `${ign(pd.wei)} at ${pd.chain_block}${rec.paid.length > 1 ? ` (x${rec.paid.length} lines)` : ''}` : sh.paid ? `plan says paid (${JSON.stringify(sh.paid).slice(0, 40)})` : '';
const pcs = r.pc2.filter((e) => e.shard === sh.index);
const asg = pcs.find((e) => e.kind === 'assigned'), prv = pcs.find((e) => e.kind === 'proven'), ppd = pcs.find((e) => e.kind === 'paid'), pfl = pcs.filter((e) => e.kind === 'failed');
const pc = [asg ? `assigned ${new Date(asg.t).toISOString().slice(11, 19)}` : '', prv ? `proven+submitted ${prv.prove_s} s` : '', ppd ? `paid ${ppd.ign} IGN` : '', ...pfl.map((e) => `FAILED: ${e.text.slice(0, 120)}`)].filter(Boolean).join('; ');
console.log(`| ${r.block} | ${r.txs_included_by_txgen} | ${sh.index} | ${sh.txs} | ${sh.pgas?.toLocaleString('en-GB')} | ${sh.gas?.toLocaleString('en-GB')} | ${acc} | ${verify} | ${verdict} | ${paid} | ${pc} |`);
if (sh.pgas > 0) { sum.content_shards++; if (pd) sum.content_shards_paid++; if (v) sum.content_shards_verified++; if (rj) sum.content_shards_rejected++; }
else if (pd) sum.empty_shards_paid++;
if (pd) sum.paid_ign_window += Number(ign(pd.wei));
}
}
const proveTimes = pc2.filter((e) => e.kind === 'proven' && e.t >= from && e.t <= to);
const contentSet = new Set(rows.filter((r) => r.shards.some((x) => x.pgas > 0)).map((r) => r.block));
const pt = (list) => { const v = list.map((e) => e.prove_s).sort((a, b) => a - b); return v.length ? `${v[Math.floor(v.length / 2)]} / ${v.at(-1)} (n=${v.length})` : '-'; };
const allPaid = recs.filter((r) => r.kind === 'paid'), allVer = recs.filter((r) => r.kind === 'verified'), allRej = recs.filter((r) => r.kind === 'rejected');
const vt = allVer.map((r) => r.verify_wall_s).filter((x) => x != null).sort((a, b) => a - b);
console.log(`\n## Window totals (node log, ${new Date(from).toISOString().slice(11, 19)} to ${new Date(to).toISOString().slice(11, 19)} UTC)\n`);
console.log('| What | Count |\n|---|---|');
console.log(`| Blocks listed (txgen content or a proof record) | ${rows.length} |`);
console.log(`| Blocks whose plan has a shard with pgas > 0 | ${sum.content_blocks} |`);
console.log(`| Shards with pgas > 0: total / verified / rejected / paid | ${sum.content_shards} / ${sum.content_shards_verified} / ${sum.content_shards_rejected} / ${sum.content_shards_paid} |`);
console.log(`| Empty shards paid in the window (listed blocks) | ${sum.empty_shards_paid} |`);
console.log(`| Proof records accepted / verified / rejected / paid (all blocks) | ${recs.filter((r) => r.kind === 'accepted').length} / ${allVer.length} / ${allRej.length} / ${allPaid.length} |`);
console.log(`| Verify wall time on the Mac, p50 / max (s) | ${vt.length ? `${vt[Math.floor(vt.length / 2)]} / ${vt.at(-1)}` : '-'} |`);
console.log(`| Paid in the window (IGN, listed blocks) | ${sum.paid_ign_window.toFixed(6)} |`);
if (pc2.length) {
console.log(`| PC 2 prove+submit time, content shards, p50 / max (s) | ${pt(proveTimes.filter((e) => contentSet.has(e.block)))} |`);
console.log(`| PC 2 prove+submit time, empty shards, p50 / max (s) | ${pt(proveTimes.filter((e) => !contentSet.has(e.block)))} |`);
console.log(`| PC 2 failed lines in the window | ${pc2.filter((e) => e.kind === 'failed' && e.t >= from && e.t <= to).length} |`);
for (const e of pc2.filter((x) => x.kind === 'failed' && x.t >= from && x.t <= to).slice(0, 10)) console.log(` pc2 failed: ${e.text}`);
}
console.log(`| Exec-layer error lines in the window | ${recs.filter((r) => r.kind === 'error').length} |`);
for (const r of recs.filter((x) => x.kind === 'error').slice(0, 10)) console.log(` error: ${r.text}`);
for (const r of allRej.slice(0, 10)) console.log(` rejected: block ${r.block} shard ${r.shard} by ${r.key}: ${r.text}`);
const out = { window: { from: new Date(from).toISOString(), to: new Date(to).toISOString() }, node_log: logFile, totals: sum, rows };
if (opt('--json', null)) writeFileSync(opt('--json'), JSON.stringify(out, null, 2) + '\n');
}
try {
if (mode === 'watch') await watch();
else if (mode === 'report') await report();
else { console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n')); process.exit(mode ? 2 : 0); }
} catch (e) { console.error('FAILED:', e.message); process.exit(1); }

380
tools/txgen/run.mjs Normal file
View file

@ -0,0 +1,380 @@
#!/usr/bin/env node
// Igneum devnet transaction generator (5 October 2026). Funds N generated wallets from the devnet dev-fee key, then
// sends transfers between them at a steady rate through one node's EVM JSON-RPC, so blocks carry content and the
// proving layer gets shards with pgas above zero. Until this tool the devnet had no transactions at all: every shard
// proven, verified and paid before 5 October 2026 was empty (0 pgas).
//
// tools/lock/with-lock.sh run node tools/txgen/run.mjs [--wallets 8] [--rate 2] [--duration 1200] [--fund 2]
// [--cap 20] [--rpc http://127.0.0.1:26800] [--keys ~/.config/igneum/txgen/wallets.json] [--summary <file>]
//
// Options. --wallets: generated wallets (default 8). --rate: transfers per second (default 2, decimals allowed).
// --duration: seconds of sending (default 1200); 0 sends until a signal. --fund: IGN sent to each wallet whose
// balance is under half of it (default 2). --cap: the most IGN this run may spend, funding plus fees, before it stops
// sending (default 20; values moved between the generated wallets stay in them and are not spend). --keys: the
// wallets' keys, created at mode 0600 under ~/.config/igneum/txgen/ and reused on the next run (never in the repo).
// A wallet keeps at most 14 transfers in flight (the pool refuses a nonce more than 16 ahead of the account; a tick
// with every wallet full is counted throttled) and a pool refusal for queue depth is counted deferred, not failed.
// Inclusion through ONE node is paced by the pool's hand-out cooldown (a sender's run is excluded from new templates
// for 4 s after it is handed to one, and this node's miner finds about 1 block in 12), so 16 wallets at 2/s keep
// every queue under the gap where 8 would not; the summary's throughput is what the chain actually included.
// --funder: the dev-fee file (default ~/.config/igneum/dev-fee-devnet.json, devnet only). --summary: the summary
// JSON (default next to the keys file, stamped with the start time). --stale: seconds before a transaction with no
// receipt is checked with igneum_getTransactionStatus and counted dropped or skipped (default 90).
//
// Fees: the tip is the node's eth_maxPriorityFeePerGas (1 gwei on the devnet), the fee cap is the higher of the
// node's eth_gasPrice quote (f_e + f_p x ratio + tip, 3 gwei on the devnet) and twice the execution base fee plus the
// tip, re-read every 30 s. The gas limit is the node's eth_estimateGas for a transfer (it folds the proving charge:
// 25,380 on the devnet) with a floor of 21,000. Nonces are tracked locally per wallet, re-synced from the node's
// pending nonce (account nonce plus its queue) and the send retried once on "nonce too low". Sends from one wallet are serialised; the cadence is kept by a drift-
// corrected timer, so a slow RPC answer does not stretch the run.
//
// Output: one line per 100 sends (sent, included, pending, failures, included rate, latency), one at every fee change
// and at every funding step, and the summary JSON at the end. A clean stop (duration reached, SIGINT or SIGTERM)
// stops sending, waits up to 60 s for the receipts, reads the final balances and writes the summary; the funds stay
// in the generated wallets for the next run. No key is ever printed.
//
// Dependency: viem (EIP-1559 signing), the library tools/evm-smoke and tools/exec-attacks already use; `npm install`
// in this folder once (node_modules is ignored). Node 22.
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
const args = process.argv.slice(2);
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : dflt; };
if (args.includes('--help') || args.includes('-h')) {
console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3)).join('\n'));
process.exit(0);
}
const RPC = opt('--rpc', 'http://127.0.0.1:26800');
const WALLETS = Math.max(2, +opt('--wallets', 8));
const RATE = +opt('--rate', 2);
const DURATION = +opt('--duration', 1200);
const STALE_MS = 1000 * +opt('--stale', 90);
const TXGEN_DIR = join(homedir(), '.config', 'igneum', 'txgen');
const KEYS = opt('--keys', join(TXGEN_DIR, 'wallets.json'));
const FUNDER_FILE = opt('--funder', join(homedir(), '.config', 'igneum', 'dev-fee-devnet.json'));
const startedAt = new Date();
const stamp = startedAt.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const SUMMARY = opt('--summary', join(dirname(KEYS), `summary-${stamp}.json`));
const WEI = 10n ** 18n, GWEI = 10n ** 9n;
const parseIgn = (s) => { const [i, f = ''] = String(s).split('.'); return BigInt(i || 0) * WEI + BigInt((f + '0'.repeat(18)).slice(0, 18)); };
const FUND = parseIgn(opt('--fund', '2'));
const CAP = parseIgn(opt('--cap', '20'));
const ign = (w) => (Number(w) / 1e18).toFixed(6);
const gwei = (w) => (Number(w) / 1e9).toFixed(2);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
if (!(RATE > 0) || !(DURATION >= 0)) { console.error('bad --rate or --duration'); process.exit(2); }
// ---- JSON-RPC ----
let rpcId = 0;
class RpcError extends Error { constructor(e) { super(typeof e === 'string' ? e : `${e.message || 'rpc error'}${e.data ? ` (${JSON.stringify(e.data)})` : ''}`); this.code = e.code; } }
async function post(body) {
const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) });
if (!r.ok) throw new RpcError(`http ${r.status}`);
return r.json();
}
async function rpc(method, params = []) {
const j = await post({ jsonrpc: '2.0', id: ++rpcId, method, params });
if (j.error) throw new RpcError(j.error);
return j.result;
}
// a batch keeps the order of its calls; each entry is {result} or {error}
async function rpcBatch(calls) {
if (!calls.length) return [];
const ids = calls.map(() => ++rpcId);
const j = await post(calls.map(([method, params], i) => ({ jsonrpc: '2.0', id: ids[i], method, params })));
const byId = new Map((Array.isArray(j) ? j : [j]).map((e) => [e.id, e]));
return ids.map((id) => { const e = byId.get(id); return e ? { result: e.result, error: e.error } : { error: { message: 'missing from the batch answer' } }; });
}
// ---- keys ----
function loadFunder() {
if (!existsSync(FUNDER_FILE)) throw new Error(`funder file missing: ${FUNDER_FILE}`);
const f = JSON.parse(readFileSync(FUNDER_FILE, 'utf8'));
if (!/devnet/i.test(f.purpose || '') || !f.private_key) throw new Error('the funder file is not the devnet dev-fee file');
const account = privateKeyToAccount(f.private_key);
if (account.address.toLowerCase() !== String(f.address).toLowerCase()) throw new Error('the funder file address does not match its key');
return account;
}
function loadOrCreateWallets(chainId) {
mkdirSync(dirname(KEYS), { recursive: true, mode: 0o700 });
let file = existsSync(KEYS) ? JSON.parse(readFileSync(KEYS, 'utf8')) : { purpose: 'Igneum DEVNET txgen wallets (devnet only, never mainnet)', chain_id: chainId, created: Math.floor(Date.now() / 1000), wallets: [] };
if (file.chain_id !== chainId) throw new Error(`${KEYS} is for chain ${file.chain_id}, the node is chain ${chainId}`);
let added = 0;
while (file.wallets.length < WALLETS) {
const pk = generatePrivateKey();
file.wallets.push({ index: file.wallets.length, address: privateKeyToAccount(pk).address, private_key: pk });
added++;
}
if (added || !existsSync(KEYS)) { writeFileSync(KEYS, JSON.stringify(file, null, 2) + '\n', { mode: 0o600 }); chmodSync(KEYS, 0o600); }
return { wallets: file.wallets.slice(0, WALLETS).map((w) => ({ i: w.index, address: w.address, account: privateKeyToAccount(w.private_key) })), added, total: file.wallets.length };
}
// ---- state ----
const stats = { sent: 0, included: 0, failed: 0, dropped: 0, skipped: 0, reverted: 0, nonceRetries: 0, deferred: 0, throttled: 0, fundingTx: 0 };
const errors = new Map(); // message -> count
const latencies = []; // ms, send to receipt
const perBlock = new Map(); // block number -> included count
const pending = new Map(); // hash -> {w, to, value, nonce, sentAt, nowhereAt}
const lost = []; // dropped or skipped: {hash, wallet, nonce, verdict}, listed in the summary for a later check
let spendFunding = 0n, spendFeesMax = 0n, spendFeesActual = 0n, valueMoved = 0n;
let fees = { base: GWEI, pbase: GWEI, tip: GWEI, maxFee: 3n * GWEI, quoted: 3n * GWEI };
let GAS = 21000n, CHAIN = 0, running = true, stopReason = '', capHit = false;
let firstSentAt = 0, lastIncludedAt = 0, firstBlock = 0, lastBlock = 0;
let funder, wallets;
const fail = (msg) => { stats.failed++; errors.set(msg, (errors.get(msg) || 0) + 1); };
async function refreshFees(announce) {
const [gp, tipQ, fh] = await Promise.all([
rpc('eth_gasPrice'), rpc('eth_maxPriorityFeePerGas').catch(() => null), rpc('eth_feeHistory', ['0x1', 'latest', []]).catch(() => null),
]);
const base = fh?.baseFeePerGas?.length ? BigInt(fh.baseFeePerGas.at(-1)) : GWEI;
const pbase = fh?.igneum?.provingBaseFeePerGas?.length ? BigInt(fh.igneum.provingBaseFeePerGas.at(-1)) : GWEI;
const tip = tipQ ? BigInt(tipQ) : GWEI;
const quoted = BigInt(gp);
const floor = 2n * base + tip;
const maxFee = quoted > floor ? quoted : floor;
const changed = maxFee !== fees.maxFee || tip !== fees.tip || base !== fees.base || pbase !== fees.pbase;
fees = { base, pbase, tip, maxFee, quoted };
if (changed || announce) log(`fees: base ${gwei(base)} gwei, proving base ${gwei(pbase)} gwei, tip ${gwei(tip)} gwei, node quote ${gwei(quoted)} gwei, fee cap used ${gwei(maxFee)} gwei, gas ${GAS}`);
}
// the pool's pending nonce (rpc.rs: `pool.pending_nonce`, the account nonce plus the contiguous queue), so a resync
// never collides with what this tool already queued; 'latest' only when the node refuses the tag
const chainNonce = (address) => rpc('eth_getTransactionCount', [address, 'pending']).catch(() => rpc('eth_getTransactionCount', [address, 'latest'])).then((n) => Number(BigInt(n)));
const balanceOf = (address) => rpc('eth_getBalance', [address, 'latest']).then(BigInt);
async function refreshBalances() {
const res = await rpcBatch(wallets.map((w) => ['eth_getBalance', [w.address, 'latest']]));
res.forEach((r, i) => { if (r.result !== undefined) wallets[i].balance = BigInt(r.result); });
}
async function signAndSend(account, fields) {
const raw = await account.signTransaction({ type: 'eip1559', chainId: CHAIN, gas: GAS, maxFeePerGas: fees.maxFee, maxPriorityFeePerGas: fees.tip, ...fields });
return rpc('eth_sendRawTransaction', [raw]);
}
// ---- funding ----
async function fund() {
const bal = await balanceOf(funder.address);
let nonce = await chainNonce(funder.address);
const need = [];
for (const w of wallets) { w.balance = await balanceOf(w.address); if (w.balance < FUND / 2n) need.push(w); }
log(`funder ${funder.address}: ${ign(bal)} IGN, nonce ${nonce}; ${need.length} of ${wallets.length} wallets need ${ign(FUND)} IGN`);
if (!need.length) return;
const total = FUND * BigInt(need.length) + GAS * fees.maxFee * BigInt(need.length);
if (total > CAP) throw new Error(`funding ${ign(total)} IGN is over the cap ${ign(CAP)} IGN`);
if (bal < total) throw new Error(`funder holds ${ign(bal)} IGN, funding needs ${ign(total)} IGN`);
const hashes = [];
for (const w of need) {
let hash;
for (let attempt = 0; attempt < 2 && !hash; attempt++) {
try { hash = await signAndSend(funder, { nonce, to: w.address, value: FUND }); }
catch (e) { if (attempt === 0 && /nonce/i.test(e.message)) { nonce = await chainNonce(funder.address); stats.nonceRetries++; } else throw new Error(`funding wallet ${w.i}: ${e.message}`); }
}
nonce++; stats.fundingTx++; spendFunding += FUND; hashes.push([w, hash]);
}
const t0 = Date.now();
const left = new Map(hashes.map(([w, h]) => [h, w]));
while (left.size && Date.now() - t0 < 180000) {
await sleep(1000);
const hs = [...left.keys()];
const res = await rpcBatch(hs.map((h) => ['eth_getTransactionReceipt', [h]]));
res.forEach((r, i) => {
if (!r.result) return;
const w = left.get(hs[i]); left.delete(hs[i]);
if (r.result.status !== '0x1') throw new Error(`funding of wallet ${w.i} reverted (${hs[i]})`);
spendFeesActual += BigInt(r.result.gasUsed) * BigInt(r.result.effectiveGasPrice || fees.maxFee);
log(`funded wallet ${w.i} ${w.address} in block ${Number(BigInt(r.result.blockNumber))} (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
});
}
if (left.size) throw new Error(`${left.size} funding transactions have no receipt after 180 s`);
await refreshBalances();
}
// ---- sending ----
let rr = 0;
function pickPair() {
for (let k = 0; k < wallets.length; k++) {
const w = wallets[(rr + k) % wallets.length];
const value = GWEI * BigInt(100000 + Math.floor(Math.random() * 900000)); // 0.0001 to 0.001 IGN
const maxCost = value + GAS * fees.maxFee;
// at most 14 in flight per wallet: the pool refuses a nonce more than 16 ahead of the account (pool.rs MAX_NONCE_GAP)
if (w.pendingCount >= 14 || (w.deferUntil || 0) > Date.now() || w.balance < maxCost * BigInt(w.pendingCount + 2)) continue;
rr = (rr + k + 1) % wallets.length;
const to = wallets[(w.i + 1 + Math.floor(Math.random() * (wallets.length - 1))) % wallets.length];
return { w, to, value };
}
return null;
}
async function sendOne(w, to, value) {
if (capHit) return;
if (spendFunding + spendFeesMax + GAS * fees.maxFee > CAP) { capHit = true; log(`spend cap ${ign(CAP)} IGN reached: funding ${ign(spendFunding)} + fees at most ${ign(spendFeesMax)}; no more sends`); return; }
let nonce = w.nonce;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const hash = await signAndSend(w.account, { nonce, to: to.address, value });
w.nonce = nonce + 1; w.pendingCount++; w.sent++;
stats.sent++; spendFeesMax += GAS * fees.maxFee;
if (!firstSentAt) firstSentAt = Date.now();
pending.set(hash, { w, to, value, nonce, sentAt: Date.now() });
if (stats.sent % 100 === 0) progress();
return;
} catch (e) {
const m = e.message || String(e);
// the pool's own back-pressure (pool.rs: MAX_NONCE_GAP 16, MAX_QUEUED_PER_SENDER 64): the nonce was not
// consumed, the wallet waits for an inclusion; counted as deferred, not as a failure
if (/nonce gap|too many queued/i.test(m)) { stats.deferred++; w.deferUntil = Date.now() + 3000; return; }
// the chain is past our count (a dropped transaction, or another sender of this key): take the chain's nonce
// from the message or the RPC and retry once
if (attempt === 0 && /nonce too low/i.test(m)) { nonce = await chainNonce(w.address); w.nonce = nonce; stats.nonceRetries++; continue; }
fail(m.slice(0, 160));
// a collision with this wallet's own queue (after a resync that went too low): follow the pool's pending nonce
if (/already known|replacement|underpriced|nonce too low/i.test(m)) w.nonce = await chainNonce(w.address).catch(() => w.nonce);
return;
}
}
}
function progress(final) {
const span = lastIncludedAt > firstSentAt ? (lastIncludedAt - firstSentAt) / 1000 : 0;
const lat = latencies.length ? latencies.slice().sort((a, b) => a - b) : [];
const p = (q) => lat.length ? (lat[Math.min(lat.length - 1, Math.floor(q * lat.length))] / 1000).toFixed(1) : '-';
log(`${final ? 'final:' : ''} sent ${stats.sent}, included ${stats.included}, pending ${pending.size}, failures ${stats.failed} (dropped ${stats.dropped}, skipped ${stats.skipped}, reverted ${stats.reverted}, nonce retries ${stats.nonceRetries}, deferred ${stats.deferred}, throttled ${stats.throttled}); included ${span ? (stats.included / span).toFixed(2) : '-'}/s over ${span.toFixed(0)} s, latency p50 ${p(0.5)} s p90 ${p(0.9)} s, blocks with content ${perBlock.size}, fees ${ign(spendFeesActual)} IGN`);
}
// ---- receipts ----
let polling = false;
async function poll() {
if (polling || !pending.size) return;
polling = true;
try {
const hashes = [...pending.keys()];
for (let i = 0; i < hashes.length; i += 50) {
const chunk = hashes.slice(i, i + 50);
const res = await rpcBatch(chunk.map((h) => ['eth_getTransactionReceipt', [h]]));
const stale = [];
res.forEach((r, k) => {
const h = chunk[k], p = pending.get(h);
if (!p) return;
if (r.result) {
const rc = r.result, bn = Number(BigInt(rc.blockNumber));
pending.delete(h); p.w.pendingCount--;
const feePaid = BigInt(rc.gasUsed) * BigInt(rc.effectiveGasPrice || fees.maxFee);
spendFeesActual += feePaid;
if (rc.status === '0x1') {
stats.included++; valueMoved += p.value; latencies.push(Date.now() - p.sentAt);
perBlock.set(bn, (perBlock.get(bn) || 0) + 1);
lastIncludedAt = Date.now(); if (!firstBlock || bn < firstBlock) firstBlock = bn; if (bn > lastBlock) lastBlock = bn;
p.w.balance -= p.value + feePaid; p.to.balance += p.value;
} else { stats.reverted++; fail('receipt status 0x0 (reverted transfer)'); }
} else if (Date.now() - p.sentAt > STALE_MS) stale.push(h);
});
if (stale.length) {
const st = await rpcBatch(stale.map((h) => ['igneum_getTransactionStatus', [h]]));
st.forEach((r, k) => {
const h = stale[k], p = pending.get(h); if (!p || !r.result) return;
const s = r.result;
if (s.inMempool) { p.nowhereAt = null; return; } // still waiting, the node has it
// "in no block and not in the pool" is transient around a one-block reorg (service.rs unwinds the
// records and tx index, pool.rs removed the transaction when the chain block executed it, and the
// carrying block is re-merged seconds later), so a drop needs two such verdicts 60 s apart
if (!s.includedIn?.length) {
if (!p.nowhereAt) { p.nowhereAt = Date.now(); return; }
if (Date.now() - p.nowhereAt < 60000) return;
}
pending.delete(h); p.w.pendingCount--;
if (s.includedIn?.length && !s.executed) { stats.skipped++; lost.push({ hash: h, wallet: p.w.i, nonce: p.nonce, verdict: 'skipped' }); fail(`skipped in a block with no receipt after ${STALE_MS / 1000} s (state-dependent fault: nonce or balance)`); }
else { stats.dropped++; lost.push({ hash: h, wallet: p.w.i, nonce: p.nonce, verdict: 'dropped' }); fail(`dropped: not in the mempool and in no block on two checks 60 s apart`); }
chainNonce(p.w.address).then((n) => { p.w.nonce = n; }).catch(() => { }); // the next send fills the gap the drop left
});
}
}
} catch (e) { errors.set(`poll: ${e.message.slice(0, 120)}`, (errors.get(`poll: ${e.message.slice(0, 120)}`) || 0) + 1); }
finally { polling = false; }
}
// ---- summary ----
function writeSummary() {
const lat = latencies.slice().sort((a, b) => a - b);
const q = (x) => lat.length ? lat[Math.min(lat.length - 1, Math.floor(x * lat.length))] : null;
const blocks = [...perBlock.entries()].sort((a, b) => a[0] - b[0]);
const span = lastIncludedAt > firstSentAt ? (lastIncludedAt - firstSentAt) / 1000 : 0;
const summary = {
tool: 'tools/txgen/run.mjs', rpc: RPC, chain_id: CHAIN, started: startedAt.toISOString(), ended: new Date().toISOString(), stop_reason: stopReason,
params: { wallets: WALLETS, rate_per_s: RATE, duration_s: DURATION, fund_ign: ign(FUND), cap_ign: ign(CAP), gas: Number(GAS), stale_s: STALE_MS / 1000 },
fees_last: { base_gwei: gwei(fees.base), proving_base_gwei: gwei(fees.pbase), tip_gwei: gwei(fees.tip), node_quote_gwei: gwei(fees.quoted), fee_cap_gwei: gwei(fees.maxFee) },
counts: { ...stats, pending_at_end: pending.size },
throughput: { included_per_s: span ? +(stats.included / span).toFixed(3) : null, send_span_s: +span.toFixed(1), sent_per_s_target: RATE },
latency_ms: { p50: q(0.5), p90: q(0.9), p99: q(0.99), max: lat.at(-1) ?? null, mean: lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : null },
blocks: { with_content: blocks.length, first: firstBlock || null, last: lastBlock || null, max_tx_in_one: blocks.length ? Math.max(...blocks.map(([, n]) => n)) : 0, per_block: Object.fromEntries(blocks) },
spend_ign: { funding: ign(spendFunding), fees_actual: ign(spendFeesActual), fees_max_committed: ign(spendFeesMax), value_moved_between_wallets: ign(valueMoved), cap: ign(CAP), cap_hit: capHit },
wallets: wallets.map((w) => ({ index: w.i, address: w.address, sent: w.sent, balance_ign: ign(w.balance), nonce: w.nonce })),
funder: { address: funder.address, balance_ign: funder.balance != null ? ign(funder.balance) : null },
lost: lost,
pending_at_end: [...pending.entries()].map(([h, p]) => ({ hash: h, wallet: p.w.i, nonce: p.nonce, age_s: Math.round((Date.now() - p.sentAt) / 1000) })),
keys_file: KEYS, errors: Object.fromEntries([...errors.entries()].sort((a, b) => b[1] - a[1])),
};
mkdirSync(dirname(SUMMARY), { recursive: true });
writeFileSync(SUMMARY, JSON.stringify(summary, null, 2) + '\n');
return summary;
}
// ---- main ----
let signals = 0;
const onSignal = (sig) => { signals++; if (signals === 1) { stopReason = sig; running = false; log(`${sig}: stopping sends, waiting for receipts (again to quit at once)`); } else { stopReason = `${sig} x2`; writeSummary(); process.exit(130); } };
process.on('SIGINT', () => onSignal('SIGINT'));
process.on('SIGTERM', () => onSignal('SIGTERM'));
try {
CHAIN = Number(BigInt(await rpc('eth_chainId')));
const tip0 = Number(BigInt(await rpc('eth_blockNumber')));
if (CHAIN !== 4463) throw new Error(`chain id ${CHAIN} is not the devnet (4463); this tool funds from the devnet dev-fee key only`);
funder = loadFunder();
const lw = loadOrCreateWallets(CHAIN);
wallets = lw.wallets;
for (const w of wallets) { w.nonce = 0; w.balance = 0n; w.pendingCount = 0; w.sent = 0; }
log(`chain ${CHAIN} at block ${tip0} via ${RPC}; ${wallets.length} wallets from ${KEYS} (${lw.added} new, ${lw.total} on file); rate ${RATE}/s for ${DURATION || 'unbounded'} s, cap ${ign(CAP)} IGN`);
const est = await rpc('eth_estimateGas', [{ from: funder.address, to: wallets[0].address, value: '0x1' }]).then(BigInt).catch(() => 21000n);
GAS = est > 21000n ? est : 21000n;
await refreshFees(true);
await fund();
const nonces = await rpcBatch(wallets.map((w) => ['eth_getTransactionCount', [w.address, 'pending']]));
nonces.forEach((r, i) => { wallets[i].nonce = r.result ? Number(BigInt(r.result)) : 0; });
log(`wallets: ${wallets.map((w) => `${w.i}:${ign(w.balance).replace(/0+$/, '')} IGN n${w.nonce}`).join(', ')}`);
const period = 1000 / RATE;
const endAt = DURATION ? Date.now() + DURATION * 1000 : Infinity;
let next = Date.now(), lastFees = Date.now(), lastBal = Date.now();
const pollTimer = setInterval(() => { poll(); }, 1500);
log(`sending ${RATE} transfers/s between ${wallets.length} wallets`);
while (running) {
if (Date.now() >= endAt) { stopReason = 'duration'; break; }
if (capHit) { stopReason = 'cap'; break; }
const pair = pickPair();
if (pair) { const { w, to, value } = pair; w.chain = (w.chain || Promise.resolve()).then(() => sendOne(w, to, value)).catch(() => { }); }
else stats.throttled++;
if (Date.now() - lastFees > 30000) { lastFees = Date.now(); refreshFees(false).catch((e) => log('fee refresh failed:', e.message)); }
if (Date.now() - lastBal > 30000) { lastBal = Date.now(); refreshBalances().catch(() => { }); }
next += period;
const wait = next - Date.now();
if (wait > 0) await sleep(wait); else if (wait < -5000) next = Date.now();
}
log(`sending stopped (${stopReason}); ${pending.size} pending`);
await Promise.all(wallets.map((w) => w.chain || Promise.resolve()));
const drainUntil = Date.now() + 60000;
while (pending.size && Date.now() < drainUntil) { await sleep(1500); await poll(); }
clearInterval(pollTimer);
await refreshBalances();
funder.balance = await balanceOf(funder.address).catch(() => null);
progress(true);
const s = writeSummary();
log(`summary: ${SUMMARY}`);
log(`wallets hold ${ign(wallets.reduce((a, w) => a + w.balance, 0n))} IGN in all; funder ${s.funder.balance_ign ?? '?'} IGN; spend ${ign(spendFunding + spendFeesActual)} IGN (funding ${ign(spendFunding)}, fees ${ign(spendFeesActual)})`);
if (errors.size) for (const [m, n] of errors) log(`error x${n}: ${m}`);
process.exit(stats.included > 0 || stats.sent === 0 ? 0 : 1);
} catch (e) {
console.error('FAILED:', e.message);
try { if (funder && wallets) writeSummary(); } catch { }
process.exit(1);
}