diff --git a/docs/bench-log.md b/docs/bench-log.md index ddab9e480..d32721bf1 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1508,3 +1508,39 @@ What is measured: one BLS12-381 aggregate signature over 16 summed G1 keys plus | on, split 90 s | v3 | 0 / 2 | none / 3 | 278 / 265 | apart | none | 3 on n0 | 2 (n0 reconnected 6 s after the heal, A's chain at about 58 DAA, inside the table) | Reading (the NEW finding, ledger C4). With the module off GHOSTDAG alone converges on the heavier chain and the losing side's records re-determine (F24 works when the chain moves). With the module on the overlay holds during the split (A, with 30% of the frozen table, locks nothing; B locks 7 and 8) and then fails at the heal in the shipped node: B's certificates for blocks off n0's chain are "kept pending until the chain decides (no lock at this index)", n0's chain never decides because GHOSTDAG keeps its heavier tip and nothing turns the certificate into a fork-choice constraint, and once n0's last lock (index 7, DAA 209) is one window old (DAA 329) the frozen table stops applying on A's chain ("no frozen table (no lock on this chain inside the window)"), A's two keys are 100% of A's own window (B's post-cut blocks are red there) and n0 locks 10, 11, 12 alone; B's certificates for 10 and 11 then log CONFLICTING on n0 (n0 log, 17:27:04 to 17:29:54 BST). A finality fork from a 96-s honest partition, no attacker, table intact at the heal; the 150-s run and the v2 control end the same way. The spec's fork choice ("GHOSTDAG among tips through all certified checkpoints", 3.5) is therefore implemented only for certificates over blocks already on the node's chain. Fix named in the ledger entry: verify an off-chain certificate against the table at its own block and let it constrain fork choice (a certificate-driven reorg), then re-determine. Raw: `scratchpad fud-a/c4-results-*.md`, node logs `c4-on90-tmp/`, `c4-v2-control-tmp/`. + +## 5 October 2026 (evening), EVM transaction relay: three nodes in a chain, every transaction sent to one end included by the other two miners (execution and networking engineer) + +Until this change the node did not relay EVM transactions to its peers, so a transaction sent to one node was only ever included by that node's own templates (this file, "5 October 2026 (afternoon), live devnet: real transactions": 3,794 transfers, all in the Mac's blocks; execution-layer ledger item 9). Fork branch `tx-gossip` (worktree `vendor/igneum-node-txgossip`, from release-0.3.6 a24ab01a, commit e242acd0), main repo branch `tx-gossip`. Design in `docs/design/execution-layer.md` 1.4 "Relay"; the hand-out cooldown of its 10.2 table is gone with it (row "Mempool hold"). + +What was built. Three p2p messages after Kaspa's own transaction relay (`protocol/flows/src/v10/txrelay/flow.rs`): an inventory of admitted hashes, a request for the unknown ones, one answer with the raw bytes (`protocol/p2p/proto/p2p.proto`, payload numbers 72 to 74). Two flows per peer (`protocol/flows/src/v10/evmrelay.rs`), a pump that announces the mempool's admitted hashes every 250 ms, a sink trait the execution layer implements (`kaspa_consensus_core::evm::EvmTxSink`, `igneum/exec/src/service.rs EvmTxRelaySink`), and the mempool's side: every admitted hash queued for gossip, executed, evicted and invalid hashes remembered (65,536) so a second announcement is not requested, a 50,000-transaction cap, and the hold on block-added in place of the 4-second cooldown (the executor subscribes to consensus `BlockAdded`; a transaction leaves the templates when any DAG block carries it and comes back if a chain block skipped it). Limits per peer in the table. + +| Limit | Value | Over it | +|---|---|---| +| Hashes announced to us, or requested from us | 2,000 per second, burst 8,192 | the surplus of the message is dropped (the sender paid as much as we did) | +| Hashes per inventory or request message | 4,096 | disconnect | +| Bytes per answer / per transaction | 4 MiB / 128 KiB | disconnect | +| Transaction failing a state-free rule (malformed, signature, chain id, type 3 or 4) | | disconnect, hash remembered | +| State-dependent refusal (nonce more than 16 ahead, fee cap under the base fee, funds, 64 queued per sender, pool full) | | dropped quietly, hash not remembered | + +Protocol version. 13 to 14. An Igneum node drops a connection on a payload it cannot decode (`protocol/p2p/src/core/router.rs route_to_flow`: prost leaves the oneof empty, the router returns "empty payload", the connection closes), so the three messages go only to peers that advertised 14 or later, exactly as the finality (12) and proof-record (13) messages did. A 14 node registers the 13 flows for a 13 peer and never announces to it. The consensus params digest does not cover the protocol version: a scratch node on the devnet profile from the shipped 0.3.6 binary (`target-036`) and from this build printed the same digest, `9409dedac4bf9f0f20a54fb169b52a75a2903364909fe9ffd6fc5cdcd9d95d38`, so a 14 node and a 13 node still peer. Rollout: during the mixed fleet a transaction reaches the 14 nodes connected to the node it was sent to, and whatever a 13 node mines carries only what its own RPC received, as today; the relay is complete when the last miner is on 14. No fresh chain, no activation height. + +Unit tests (PC 2, job build-20261005-173606, `igneum-exec` 15 of 15 in 0.01 s, `kaspa-p2p-flows` 33 of 33 in 0.19 s, 24 s for both): the pool queues an admitted hash for gossip once and answers "known" for the duplicate; wrong chain id, a signature above the curve order and truncated bytes are refused and remembered by hash, a nonce beyond the gap and a fee cap under the base fee are refused and not remembered; a transaction stays in every template until a block carries it, is held then, comes back when a chain block skips it and leaves (hash remembered) when one executes it; the wire messages round-trip through prost and the router's payload type, hash lists of the wrong length or over 4,096 are refused, the per-peer bucket grants the burst then the rate. The first PC 2 run of the suites (build-20261005-173013) failed on the signature case: a flipped low bit of `s` recovers a different signer (a funds refusal), not a fault; the test now sets `s` above the curve order. Found on the way: the `kaspa-p2p-flows` test target had not compiled since M20 added the epoch-seed headers to the pruning proof messages (`ibd/proof.rs` tests), fixed in the same commit. + +The 3-node run (`tools/txgen/relay-net.mjs`, new; this Mac, load 7 to 8 at the end of the run after the other agents' harnesses finished, every number a count or an inclusion latency, not a timing of the node). Fast-time profile (`infra/fast-time/override-60x.json`, proof of work skipped), ports 29700+, data `/tmp/igneum-txrelay`. Chain A - B - C: B dials A and C (a harness node that dials accepts no inbound, and `--connect` takes one address per flag; both found by the first two runs, which are not numbers). A mines nothing. One vmine on B and one on C at 0.5 blocks/s each, paid to throwaway keys made for the run; B's rewards funded 16 generator wallets (2 IGN each) 33 s after start. The generator (`tools/txgen/run.mjs`) sent to A's EVM RPC only, 2 transfers a second for 120 s, so every inclusion is by a block B or C built from a pool the relay fed; C is two hops from A. Result files `docs/benchmarks/evm-relay-2026-10-05/{relay-report,txgen-summary}.json`. + +| Measured, 3-node fast-time run (17:49 to 17:52 UTC) | Value | +|---|---| +| Sent to A / included / pending at the end / failures | 240 / 240 / 0 / 0 (0 nonce retries, 0 deferred, 0 throttled) | +| Included per second over the send span | 1.98 (target 2) | +| Inclusion latency p50 / p90 / p99 / max | 1,545 / 3,058 / 5,033 / 6,017 ms (mean 1,859) | +| Chain blocks in the window / executed transfers / skipped copies | 149 / 256 (240 transfers and 16 funding) / 0 | +| Included by miner B (one hop): blocks / with transactions / executed | 79 / 59 / 151 | +| Included by miner C (two hops): blocks / with transactions / executed | 70 / 42 / 105 | +| Pool depth, sampled every 5 s on A, B and C | equal on all three at 27 of 27 samples (0 to 6 pending), peak 6 | +| Sinks agree at the end | yes | +| First funding transfer, sent to A, included | 2.0 s after the send (block 37, mined by B or C) | + +Reading. Every transaction given to A was mined by B or C within 6 s, two thirds of them within 3 s, with no skipped copy: the hold on block-added kept B's and C's parallel blocks from carrying the same transfer. The afternoon run on the live devnet, through one node with the cooldown, had p50 40.7 s and p90 110.8 s with 50-s quiet stretches; here the 1.5 s p50 is one fast-time block plus the relay and the executor's lag. The pool depth matching on all three nodes at every sample is the convergence. Not measured here: a transaction flood above the per-peer rate (the bucket is unit-tested only), a 13 peer in the fleet (the digest check and the version gate are the evidence), and the hold's 30-s expiry on a block that never reaches the chain (not seen in 149 chain blocks). + +Commands: `IGNEUMD=vendor/igneum-node/target-txgossip/release/igneumd IGNEUM_MINER=vendor/igneum-node/target-txgossip/release/igneum-miner tools/lock/with-lock.sh run node tools/txgen/relay-net.mjs --rate 2 --duration 120 --wallets 16 --fund 2`; the Mac binaries from the fork worktree with `CARGO_TARGET_DIR=vendor/igneum-node/target-txgossip cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow` under the build lock (an APFS clone of `target-036`, 2 min 15 s to clone, 5 min 06 s to build); the suites with `node tools/build-job.mjs run --target 1ccfe586 --node vendor/igneum-node-txgossip --targets linux --node-tests "igneum-exec kaspa-p2p-flows" --no-app`. diff --git a/docs/benchmarks/evm-relay-2026-10-05/relay-report.json b/docs/benchmarks/evm-relay-2026-10-05/relay-report.json new file mode 100644 index 000000000..c1c24c321 --- /dev/null +++ b/docs/benchmarks/evm-relay-2026-10-05/relay-report.json @@ -0,0 +1,301 @@ +{ + "tool": "tools/txgen/relay-net.mjs", + "node": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-txgossip/release/igneumd", + "topology": "A - B - C (B dials A and C); generator on A; vmine on B and C", + "params": { + "rate_per_s": 2, + "duration_s": 120, + "wallets": 16, + "fund_ign": 2, + "fast_time": true + }, + "chain_blocks_in_window": 149, + "executed": 256, + "skipped": 0, + "by_miner": { + "B": { + "blocks": 79, + "blocks_with_txs": 59, + "txs_carried": 151, + "executed": 151, + "skipped": 0 + }, + "C": { + "blocks": 70, + "blocks_with_txs": 42, + "txs_carried": 105, + "executed": 105, + "skipped": 0 + } + }, + "generator": { + "sent": 240, + "included": 240, + "pending_at_end": 0, + "included_per_s": 1.975, + "latency_ms": { + "p50": 1545, + "p90": 3058, + "p99": 5033, + "max": 6017, + "mean": 1859 + }, + "blocks_with_content": 100, + "errors": {}, + "stop_reason": "duration" + }, + "pools": { + "samples": [ + { + "t": 33.5, + "A": 0, + "A_chain": 37, + "B": 0, + "B_chain": 37, + "C": 0, + "C_chain": 37 + }, + { + "t": 38.5, + "A": 6, + "A_chain": 38, + "B": 6, + "B_chain": 38, + "C": 6, + "C_chain": 38 + }, + { + "t": 43.5, + "A": 2, + "A_chain": 44, + "B": 2, + "B_chain": 44, + "C": 2, + "C_chain": 44 + }, + { + "t": 48.5, + "A": 2, + "A_chain": 50, + "B": 2, + "B_chain": 50, + "C": 2, + "C_chain": 50 + }, + { + "t": 53.5, + "A": 1, + "A_chain": 58, + "B": 1, + "B_chain": 58, + "C": 1, + "C_chain": 58 + }, + { + "t": 58.5, + "A": 2, + "A_chain": 66, + "B": 2, + "B_chain": 66, + "C": 2, + "C_chain": 66 + }, + { + "t": 63.6, + "A": 4, + "A_chain": 71, + "B": 4, + "B_chain": 71, + "C": 4, + "C_chain": 71 + }, + { + "t": 68.6, + "A": 2, + "A_chain": 77, + "B": 2, + "B_chain": 77, + "C": 2, + "C_chain": 77 + }, + { + "t": 73.6, + "A": 0, + "A_chain": 80, + "B": 0, + "B_chain": 80, + "C": 0, + "C_chain": 80 + }, + { + "t": 78.6, + "A": 3, + "A_chain": 83, + "B": 3, + "B_chain": 83, + "C": 3, + "C_chain": 83 + }, + { + "t": 83.6, + "A": 0, + "A_chain": 92, + "B": 0, + "B_chain": 92, + "C": 0, + "C_chain": 92 + }, + { + "t": 88.6, + "A": 0, + "A_chain": 96, + "B": 0, + "B_chain": 96, + "C": 0, + "C_chain": 96 + }, + { + "t": 93.6, + "A": 2, + "A_chain": 101, + "B": 2, + "B_chain": 101, + "C": 2, + "C_chain": 101 + }, + { + "t": 98.6, + "A": 1, + "A_chain": 114, + "B": 1, + "B_chain": 114, + "C": 1, + "C_chain": 114 + }, + { + "t": 103.6, + "A": 5, + "A_chain": 117, + "B": 5, + "B_chain": 117, + "C": 5, + "C_chain": 117 + }, + { + "t": 108.6, + "A": 4, + "A_chain": 121, + "B": 4, + "B_chain": 121, + "C": 4, + "C_chain": 121 + }, + { + "t": 113.6, + "A": 2, + "A_chain": 125, + "B": 2, + "B_chain": 125, + "C": 2, + "C_chain": 125 + }, + { + "t": 118.6, + "A": 1, + "A_chain": 128, + "B": 1, + "B_chain": 128, + "C": 1, + "C_chain": 128 + }, + { + "t": 123.6, + "A": 0, + "A_chain": 135, + "B": 0, + "B_chain": 135, + "C": 0, + "C_chain": 135 + }, + { + "t": 128.6, + "A": 3, + "A_chain": 140, + "B": 3, + "B_chain": 140, + "C": 3, + "C_chain": 140 + }, + { + "t": 133.6, + "A": 0, + "A_chain": 144, + "B": 0, + "B_chain": 144, + "C": 0, + "C_chain": 144 + }, + { + "t": 138.6, + "A": 1, + "A_chain": 155, + "B": 1, + "B_chain": 155, + "C": 1, + "C_chain": 155 + }, + { + "t": 143.6, + "A": 3, + "A_chain": 163, + "B": 3, + "B_chain": 163, + "C": 3, + "C_chain": 163 + }, + { + "t": 148.6, + "A": 2, + "A_chain": 167, + "B": 2, + "B_chain": 167, + "C": 2, + "C_chain": 167 + }, + { + "t": 153.6, + "A": 1, + "A_chain": 175, + "B": 1, + "B_chain": 175, + "C": 1, + "C_chain": 175 + }, + { + "t": 158.6, + "A": 0, + "A_chain": 179, + "B": 0, + "B_chain": 179, + "C": 0, + "C_chain": 179 + }, + { + "t": 163.6, + "A": 0, + "A_chain": 184, + "B": 0, + "B_chain": 184, + "C": 0, + "C_chain": 184 + } + ], + "max": { + "A": 6, + "B": 6, + "C": 6 + } + }, + "sinks_agree": true, + "wall_s": 168.8 +} diff --git a/docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json b/docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json new file mode 100644 index 000000000..d681ca3af --- /dev/null +++ b/docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json @@ -0,0 +1,269 @@ +{ + "tool": "tools/txgen/run.mjs", + "rpc": "http://127.0.0.1:29703", + "chain_id": 4463, + "started": "2026-10-05T17:49:40.914Z", + "ended": "2026-10-05T17:51:44.556Z", + "stop_reason": "duration", + "params": { + "wallets": 16, + "rate_per_s": 2, + "duration_s": 120, + "fund_ign": "2.000000", + "cap_ign": "74.000000", + "gas": 59650, + "stale_s": 60 + }, + "fees_last": { + "base_gwei": "100.00", + "proving_base_gwei": "10000.00", + "tip_gwei": "1.00", + "node_quote_gwei": "243.86", + "fee_cap_gwei": "243.86" + }, + "counts": { + "sent": 240, + "included": 240, + "failed": 0, + "dropped": 0, + "skipped": 0, + "reverted": 0, + "nonceRetries": 0, + "deferred": 0, + "throttled": 0, + "fundingTx": 16, + "pending_at_end": 0 + }, + "throughput": { + "included_per_s": 1.975, + "send_span_s": 121.5, + "sent_per_s_target": 2 + }, + "latency_ms": { + "p50": 1545, + "p90": 3058, + "p99": 5033, + "max": 6017, + "mean": 1859 + }, + "blocks": { + "with_content": 100, + "first": 38, + "last": 176, + "max_tx_in_one": 10, + "per_block": { + "38": 8, + "39": 1, + "40": 2, + "42": 3, + "44": 5, + "45": 3, + "46": 1, + "48": 1, + "50": 2, + "51": 1, + "52": 3, + "54": 2, + "57": 3, + "58": 2, + "59": 1, + "61": 2, + "62": 2, + "63": 1, + "65": 1, + "66": 3, + "68": 4, + "70": 1, + "71": 5, + "72": 1, + "73": 2, + "74": 2, + "75": 2, + "77": 2, + "79": 10, + "80": 1, + "81": 4, + "82": 2, + "83": 3, + "84": 2, + "85": 3, + "87": 1, + "88": 2, + "89": 1, + "91": 1, + "92": 4, + "93": 1, + "94": 3, + "95": 2, + "96": 2, + "97": 1, + "98": 5, + "101": 2, + "103": 1, + "104": 1, + "105": 2, + "106": 2, + "109": 1, + "110": 1, + "112": 1, + "114": 3, + "116": 3, + "117": 8, + "119": 2, + "120": 1, + "121": 6, + "122": 1, + "123": 3, + "124": 2, + "125": 6, + "126": 1, + "127": 4, + "128": 3, + "129": 1, + "130": 4, + "132": 1, + "133": 1, + "134": 1, + "135": 2, + "138": 4, + "139": 1, + "140": 4, + "141": 6, + "142": 3, + "146": 1, + "147": 1, + "148": 3, + "151": 2, + "152": 1, + "153": 1, + "155": 4, + "157": 1, + "158": 1, + "160": 1, + "162": 1, + "163": 5, + "164": 1, + "165": 4, + "166": 1, + "167": 5, + "168": 1, + "169": 1, + "172": 3, + "174": 1, + "175": 3, + "176": 2 + } + }, + "spend_ign": { + "funding": "32.000000", + "fees_actual": "0.542976", + "fees_max_committed": "38.769773", + "value_moved_between_wallets": "0.132776", + "cap": "74.000000", + "cap_hit": false + }, + "wallets": [ + { + "index": 0, + "sent": 15, + "balance_ign": "1.921973", + "nonce": 15 + }, + { + "index": 1, + "sent": 15, + "balance_ign": "1.922278", + "nonce": 15 + }, + { + "index": 2, + "sent": 15, + "balance_ign": "1.923568", + "nonce": 15 + }, + { + "index": 3, + "sent": 15, + "balance_ign": "1.924152", + "nonce": 15 + }, + { + "index": 4, + "sent": 15, + "balance_ign": "1.920937", + "nonce": 15 + }, + { + "index": 5, + "sent": 15, + "balance_ign": "1.922478", + "nonce": 15 + }, + { + "index": 6, + "sent": 15, + "balance_ign": "1.924611", + "nonce": 15 + }, + { + "index": 7, + "sent": 15, + "balance_ign": "1.930741", + "nonce": 15 + }, + { + "index": 8, + "sent": 15, + "balance_ign": "1.923430", + "nonce": 15 + }, + { + "index": 9, + "sent": 15, + "balance_ign": "1.926285", + "nonce": 15 + }, + { + "index": 10, + "sent": 15, + "balance_ign": "1.922495", + "nonce": 15 + }, + { + "index": 11, + "sent": 15, + "balance_ign": "1.918612", + "nonce": 15 + }, + { + "index": 12, + "sent": 15, + "balance_ign": "1.921628", + "nonce": 15 + }, + { + "index": 13, + "sent": 15, + "balance_ign": "1.921379", + "nonce": 15 + }, + { + "index": 14, + "sent": 15, + "balance_ign": "1.923181", + "nonce": 15 + }, + { + "index": 15, + "sent": 15, + "balance_ign": "1.923212", + "nonce": 15 + } + ], + "funder": { + "balance_ign": "203.751501" + }, + "lost": [], + "pending_at_end": [], + "errors": {} +} \ No newline at end of file diff --git a/docs/design/execution-layer.md b/docs/design/execution-layer.md index dbcbd5535..f0466d162 100644 --- a/docs/design/execution-layer.md +++ b/docs/design/execution-layer.md @@ -79,6 +79,8 @@ Worked example. Sender S has nonce 5. Miner A's block carries S:5, S:6. Miner B' Consequence for users. A transaction can be skipped in one block and execute in a later one without being re-broadcast, as long as a miner includes it again; the node's mempool re-queues a skipped transaction once (then drops it). `eth_getTransactionReceipt` returns null until the executing copy lands, as on Ethereum for a pending transaction. +Relay (implemented 5 October 2026, fork `tx-gossip`, protocol version 14). A node's mempool is no longer only what its own RPC received. Every admitted hash is announced to every relay-aware peer within 250 ms (`IgneumEvmTxInvMessage`, the inventory pattern of Kaspa's `InvTransactions`); a peer requests the hashes it does not know (`IgneumRequestEvmTxsMessage`) and the holder answers one `IgneumEvmTxsMessage` with the raw bytes it still has; the receiver runs the same admission as `eth_sendRawTransaction` (signature, chain id, nonce window of 16, fee cap at or above the execution base fee, funds, 64 queued per sender, the pgas estimate) and a transaction the node already executed is refused without re-admission, so the pools converge and the chain's own blocks carry a transaction once. Dedup is by hash: the pool answers "known" for what it holds, executed or refused as invalid in the last 65,536 hashes, and one request per hash is outstanding across all peers. Per peer, 2,000 hashes a second with a burst of 8,192 are accepted in and served out; 4,096 hashes per message and 4 MiB per answer, over which the peer is dropped. A state-free fault (malformed, bad signature, wrong chain id, a refused type) disconnects the relaying peer, since every node refuses it the same way; a state-dependent refusal (nonce beyond the window, fee cap under the base fee, funds, queue depth, the 50,000-transaction pool cap) is dropped quietly, because the peer's tip may differ. Relay is off while the node is out of sync. Code: `protocol/flows/src/v10/evmrelay.rs`, the pump in `protocol/flows/src/service.rs`, the sink in `igneum/exec/src/service.rs` (`EvmTxRelaySink`). + Alternative. Per-block nonces or sequence-independent nonces (Sui-style objects). Rejected: every wallet assumes Ethereum nonces. ### 1.5 Invalid transactions are skipped by rule @@ -460,7 +462,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite, | Units | 1 sompi = 1e10 wei; 1 IGN = 1e18 wei | Subsidies come from `igneum::block_subsidy` in 8-decimal sompi; the EVM is 18-decimal. The open "8 or 18 decimals" decision is unchanged; this is the fixed scaling at the bridge named there | | Rewards | 80% of every blue block's subsidy to its miner, 20% to the proving pool escrow `0x...0220`, both credited in the segment that merges the block; reds unpaid | Design 4.4, with the pool held in a keyless account until proof records exist | | Simnet | Devnet block rate and depths (1 BPS, k 18, mergeset 180, merge depth 3,600) with proof of work skipped; chain id 4463 shared with the devnet | A CPU test network of the devnet DAG shape | -| Mempool hand-out | A transaction handed to a template is not offered again for 4 s unless a chain block skipped it; a transaction skipped twice is dropped | Kaspa removes a block's transactions on block-added; the cooldown is the stand-in until the executor listens to block-added | +| Mempool hold | A transaction stays in every template until a block carrying it is added to the DAG (any block, this node's or a peer's: the executor subscribes to consensus `BlockAdded`); then it is held for 30 s or until the executor removes it (executed) or offers it again (a chain block skipped it); a transaction skipped twice is dropped | Kaspa's own rule (`mining/src/manager.rs`, `handle_new_block_transactions`). Replaced the 4-second hand-out cooldown on 5 October 2026 (fork `tx-gossip`, `pool.rs IN_BLOCK_HOLD`, `service.rs listen_block_added`): the cooldown made a sender mineable 1 s in 5 and inclusion came in 50-s bursts (bench-log, 5 October 2026 afternoon); with the hold a transaction is offered to every template until a block has it | | Reorgs | Post-segment states for the last 64 chain blocks; deeper reorgs replay from genesis | Observed depth on the test network: 1 to 3 with Poisson-paced miners. A fixed per-template hold had made the three stub miners mine in lockstep rounds, and with equal work per block the GHOSTDAG hash tie-break then kept two equal-work chains alive from genesis (flips 48 deep every few seconds); `igneum-miner --hold-ms` is exponential now | | Block tags | `pending`, `safe` and `finalized` all resolve to the executed tip | The virtual's segment is not executed eagerly and no certified checkpoint exists on this branch; the RPC does not pretend otherwise | @@ -474,7 +476,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite, 6. The virtual's segment is not executed eagerly (design 1.2 "about one second after inclusion"); the executor runs about one chain block behind the sink. `pending` tags resolve to the executed tip. 7. `eth_subscribe`, `debug_traceTransaction`, `trace_block`, `eth_getProof`, `eth_getUncle*`, `IgneumInfo`: not implemented. 8. The chain follower polls `get_virtual_chain_from_block` every 100 ms instead of subscribing to virtual-chain-changed notifications. -9. Mempool: no p2p relay of EVM transactions between nodes (each node's pool is what its RPC received), no eviction by age, no fee-based replacement beyond the 10% rule. +9. Mempool: p2p relay of EVM transactions implemented 5 October 2026 (section 1.4 "Relay", protocol version 14; measured on a 3-node fast-time chain A - B - C in the bench-log of that day: every transaction sent to A was included by B's and C's blocks). Still missing: eviction by age and fee-based replacement beyond the 10% rule. 10. Differential rows 1 (ethereum/tests), 3 (independent linearizer), 4 (Python oracle), 5 and 6 are not built; the harness here is the balance and receipt comparison of the acceptance criteria. ### 10.4 Merge plan with the finality branch diff --git a/tools/txgen/relay-net.mjs b/tools/txgen/relay-net.mjs new file mode 100644 index 000000000..7922cf6d6 --- /dev/null +++ b/tools/txgen/relay-net.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// Igneum EVM transaction relay check (5 October 2026, execution-layer ledger item 9). A private 3-node network in a +// chain, A - B - C (B dials A and C; A and C listen and dial nothing, since a harness node that dials accepts no +// inbound connection; a transaction given to A reaches C in two hops, through B), on the 60x fast-time profile with +// proof of work skipped. Only B and C mine (one vmine each, half a block per second, paid to throwaway +// EVM keys made for the run); A mines nothing, so every transaction the generator sends to A can only be included +// by a block B or C built from a pool fed by the relay. The generator is tools/txgen/run.mjs against A's EVM RPC; +// B's block rewards fund it. The report counts inclusion by miner from A's own executed chain +// (igneum_getSegment: every mergeset block with its miner and transaction count, every executed transaction with +// its including block), samples the three pools every 5 s for convergence, and keeps the generator's latency. +// +// IGNEUMD= IGNEUM_MINER= tools/lock/with-lock.sh run node tools/txgen/relay-net.mjs +// [--rate 2] [--duration 120] [--wallets 16] [--fund 2] [--out ] +// +// Ports IGNEUM_HARNESS_BASE_PORT (default 29700) and up, data IGNEUM_HARNESS_TMP (default /tmp/igneum-txrelay). The +// live devnet (26610/26611, 26640/26641, 26800, 28640) and other agents' ranges are never touched. No key is printed. + +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync, chmodSync, readFileSync, openSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + +process.env.IGNEUM_FAST_TIME = '1'; +process.env.IGNEUM_HARNESS_BASE_PORT ||= '29700'; +process.env.IGNEUM_HARNESS_TMP ||= '/tmp/igneum-txrelay'; +const net = await import('../harness/lib/net.mjs'); +const { Node, stopAll, log, sleep, BASE_PORT, TMP, IGNEUMD, ROOT } = net; + +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; }; +const RATE = +opt('--rate', 2); +const DURATION = +opt('--duration', 120); +const WALLETS = +opt('--wallets', 16); +const FUND = +opt('--fund', 2); +const OUT = opt('--out', join(TMP, 'out')); +const MINER = process.env.IGNEUM_MINER || `${ROOT}vendor/igneum-node/target-txgossip/release/igneum-miner`; +for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); } +mkdirSync(OUT, { recursive: true }); + +const evmPort = (i) => BASE_PORT + i * 10 + 3; +const evmUrl = (i) => `http://127.0.0.1:${evmPort(i)}`; +async function eth(i, method, params = []) { + const r = await fetch(evmUrl(i), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) }); + const j = await r.json(); + if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); + return j.result; +} +const ign = (wei) => (Number(BigInt(wei) / 10n ** 14n) / 1e4).toFixed(4); + +// Throwaway keys for the two miners (never printed, never reused) +const keyB = generatePrivateKey(), keyC = generatePrivateKey(); +const minerB = privateKeyToAccount(keyB), minerC = privateKeyToAccount(keyC); +const funderFile = join(TMP, 'funder.json'); +mkdirSync(TMP, { recursive: true }); +writeFileSync(funderFile, JSON.stringify({ purpose: 'devnet relay harness, throwaway key of miner B', private_key: keyB, address: minerB.address })); +chmodSync(funderFile, 0o600); + +const started = []; +function miner(bin, argv, name) { + const out = openSync(`${TMP}/${name}.log`, 'a'); + const p = spawn(bin, argv, { stdio: ['ignore', out, out] }); + started.push(p); + return p; +} +async function cleanup() { + for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } + await sleep(1500); + for (const p of started) { try { p.kill('SIGKILL'); } catch { } } + await stopAll(); +} +process.on('SIGINT', async () => { await cleanup(); process.exit(130); }); + +const t0 = Date.now(); +const since = () => ((Date.now() - t0) / 1000).toFixed(1); +let exitCode = 1; +try { + const A = await new Node(0, { name: 'A', extraArgs: [`--evm-rpclisten=127.0.0.1:${evmPort(0)}`] }).start(); + const C = await new Node(2, { name: 'C', extraArgs: [`--evm-rpclisten=127.0.0.1:${evmPort(2)}`] }).start(); + // one --connect per peer: the node's parser takes the flag repeatedly, not a comma list + const B = await new Node(1, { name: 'B', connect: [A.p2p], extraArgs: [`--connect=${C.p2p}`, `--evm-rpclisten=127.0.0.1:${evmPort(1)}`] }).start(); + const nodes = [A, B, C]; + await sleep(3000); + const peersOf = async (n) => (await net.peers(n)).length; + log(`peers: A ${await peersOf(A)}, B ${await peersOf(B)}, C ${await peersOf(C)} (chain A - B - C)`); + const version = (n) => n.grepLog(/protocol version/).map(l => l.replace(/^.*Registering/, 'Registering')).slice(0, 2); + log(`B log: ${version(B).join(' | ')}`); + + const secs = DURATION + 400; + miner(MINER, ['vmine', B.grpc, String(secs), '--label', 'vB', '--share', '0.5', '--bps', '1', '--evm-address', minerB.address.slice(2), '--network', 'devnet'], 'vmine-B'); + miner(MINER, ['vmine', C.grpc, String(secs), '--label', 'vC', '--share', '0.5', '--bps', '1', '--evm-address', minerC.address.slice(2), '--network', 'devnet'], 'vmine-C'); + log('miners: vmine on B and C at 0.5 blocks/s each; A mines nothing'); + + // B's rewards reach A's executed state through the relay of blocks; wait for enough to fund the wallets + const wanted = BigInt(Math.ceil(WALLETS * FUND * 1.25 + 2)) * 10n ** 18n; + for (let i = 0; i < 300; i++) { + const bal = BigInt(await eth(0, 'eth_getBalance', [minerB.address, 'latest']).catch(() => '0x0')); + if (bal >= wanted) { log(`miner B holds ${ign(bal)} IGN on A's chain at ${since()} s; funding can start`); break; } + if (i % 10 === 0) log(`waiting for miner B's rewards: ${ign(bal)} IGN of ${ign(wanted)} (t=${since()} s)`); + await sleep(1000); + } + const startBlock = Number(BigInt(await eth(0, 'eth_blockNumber'))); + + // The generator against A, with the pool sampler beside it + const samples = []; + let sampling = true; + const sampler = (async () => { + while (sampling) { + const row = { t: +since() }; + for (const [i, name] of [[0, 'A'], [1, 'B'], [2, 'C']]) { + try { const b = await eth(i, 'igneum_getBudgets'); row[name] = Number(BigInt(b.mempool)); row[`${name}_chain`] = Number(BigInt(b.chainBlocks)); } catch { row[name] = null; } + } + samples.push(row); + await sleep(5000); + } + })(); + const summaryFile = join(OUT, 'txgen-summary.json'); + const genLog = openSync(join(OUT, 'txgen.log'), 'w'); + const gen = spawn(process.execPath, [`${ROOT}tools/txgen/run.mjs`, '--rpc', evmUrl(0), '--rate', String(RATE), '--duration', String(DURATION), '--wallets', String(WALLETS), + '--fund', String(FUND), '--cap', String(WALLETS * FUND * 2 + 10), '--keys', join(TMP, 'wallets.json'), '--funder', funderFile, '--summary', summaryFile, '--stale', '60'], + { stdio: ['ignore', 'pipe', genLog], cwd: `${ROOT}tools/txgen` }); + started.push(gen); + gen.stdout.on('data', (d) => { const s = d.toString(); writeFileSync(join(OUT, 'txgen.log'), s, { flag: 'a' }); for (const l of s.split('\n')) if (/included|funded|funder|fees|summary|error|stopping/i.test(l) && l.trim()) log(`txgen: ${l.trim().slice(0, 160)}`); }); + const genExit = await new Promise((r) => gen.on('exit', (code) => r(code))); + log(`generator exited ${genExit} at ${since()} s`); + await sleep(10000); // let the last blocks execute on A + sampling = false; await sampler; + + // Inclusion by miner, from A's executed chain + const endBlock = Number(BigInt(await eth(0, 'eth_blockNumber'))); + const byMiner = new Map(); + const label = (m) => (m.toLowerCase() === minerB.address.toLowerCase() ? 'B' : m.toLowerCase() === minerC.address.toLowerCase() ? 'C' : `other:${m.slice(0, 10)}`); + const bump = (m, k, n = 1) => { const r = byMiner.get(m) || { blocks: 0, blocks_with_txs: 0, txs_carried: 0, executed: 0, skipped: 0 }; r[k] += n; byMiner.set(m, r); }; + let executed = 0, skipped = 0, chainBlocks = 0; + for (let n = startBlock + 1; n <= endBlock; n++) { + let seg; try { seg = await eth(0, 'igneum_getSegment', ['0x' + n.toString(16)]); } catch (e) { log(`segment ${n}: ${e.message}`); continue; } + chainBlocks++; + const minerOfBlock = new Map(); + for (const m of seg.mergeset || []) { + const who = label(m.miner); minerOfBlock.set(m.hash, who); + bump(who, 'blocks'); if (Number(BigInt(m.txCount)) > 0) { bump(who, 'blocks_with_txs'); bump(who, 'txs_carried', Number(BigInt(m.txCount))); } + } + for (const e of seg.executed || []) { executed++; bump(minerOfBlock.get(e.includingBlock) || 'unknown', 'executed'); } + for (const s of seg.skipped || []) { skipped++; bump(minerOfBlock.get(s.includingBlock) || 'unknown', 'skipped'); } + } + const summary = existsSync(summaryFile) ? JSON.parse(readFileSync(summaryFile, 'utf8')) : null; + const sinks = await Promise.all(nodes.map(async n => { try { return (await net.dagInfo(n)).sink; } catch { return '?'; } })); + const maxPool = (k) => Math.max(0, ...samples.map(s => s[k] ?? 0)); + const report = { + tool: 'tools/txgen/relay-net.mjs', node: IGNEUMD, topology: 'A - B - C (B dials A and C); generator on A; vmine on B and C', + params: { rate_per_s: RATE, duration_s: DURATION, wallets: WALLETS, fund_ign: FUND, fast_time: true }, + chain_blocks_in_window: chainBlocks, executed, skipped, + by_miner: Object.fromEntries([...byMiner.entries()]), + generator: summary ? { sent: summary.counts?.sent, included: summary.counts?.included, pending_at_end: summary.counts?.pending_at_end, included_per_s: summary.throughput?.included_per_s, latency_ms: summary.latency_ms, blocks_with_content: summary.blocks?.with_content, errors: summary.errors, stop_reason: summary.stop_reason } : null, + pools: { samples, max: { A: maxPool('A'), B: maxPool('B'), C: maxPool('C') } }, + sinks_agree: new Set(sinks).size === 1, + wall_s: +since(), + }; + writeFileSync(join(OUT, 'relay-report.json'), JSON.stringify(report, null, 2) + '\n'); + log('RELAY REPORT'); + log(` chain blocks ${chainBlocks}, executed ${executed}, skipped copies ${skipped}, sinks agree ${report.sinks_agree}`); + for (const [m, r] of byMiner) log(` miner ${m}: blocks ${r.blocks}, with transactions ${r.blocks_with_txs}, carried ${r.txs_carried}, executed ${r.executed}, skipped ${r.skipped}`); + if (summary) log(` generator: sent ${summary.counts?.sent}, included ${summary.counts?.included}, pending ${summary.counts?.pending_at_end}, ${summary.throughput?.included_per_s}/s, latency p50 ${summary.latency_ms?.p50} p90 ${summary.latency_ms?.p90} max ${summary.latency_ms?.max} ms`); + log(` pool max: A ${report.pools.max.A}, B ${report.pools.max.B}, C ${report.pools.max.C}`); + log(` report ${join(OUT, 'relay-report.json')}`); + const included = (byMiner.get('B')?.executed || 0) + (byMiner.get('C')?.executed || 0); + exitCode = included > 0 && (byMiner.get('B')?.executed || 0) > 0 && (byMiner.get('C')?.executed || 0) > 0 ? 0 : 1; +} catch (e) { + log(`FAILED: ${e.stack || e.message}`); +} finally { + await cleanup(); +} +process.exit(exitCode);