Ledger close round 1 (sims): F20, M21, X14, E16 code half, P17, X12 run and logged
F20: tools/finality-attacks/f20.mjs, a 47.5% silent set through four fast-time epoch boundaries (0 locks in the pause, program on schedule, one seed per index, 12 pre-pause locks held, 0 conflicts). M21: tools/finality-attacks/m21.mjs, 490 KB coinbase bodies on 100-ms proxied links, k re-derived with the fork's calculate_ghostdag_k (p99 812 ms, k 5). X14 and E16: tools/finality-attacks/x14-concentration.mjs, read-only concentration from the observer node (signing not exposed by any RPC) and one live block's burn output beside its escrow payout. P17: report only, igneum_getTransactionStatus per state, finalized resolves to the tip. X12: sim/difficulty/record_report.py, the 3 October record's step profile, retarget trajectory, 2-minute buckets and epoch gap in the bench-log. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
5aa75166d8
commit
def6ef137d
6 changed files with 671 additions and 6 deletions
|
|
@ -1524,3 +1524,116 @@ 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 (night), ledger close round 1: F20 finality pause under a 45% silent set through four epoch boundaries
|
||||
|
||||
Machine: Apple M5 Max (18 cores), load average 4.5 at the start, shared with another agent's `c4.mjs` 3-node network (run slot run-0) and the live devnet nodes. Command: `tools/lock/with-lock.sh run node tools/finality-attacks/f20.mjs` (worktree `igneum-wt-ledger-sims`, branch `ledger-sims`), run slot run-1, 18:39 to 18:49 UTC, 592 s wall. Binaries: `vendor/igneum-node/target-036/release/igneumd` built from `vendor/igneum-node-036` at `a24ab01a` (release-0.3.6, the live node's binary, built 17:28 BST), `igneum-miner` from the same worktree at `2b6d23ef` (built 09:45 BST; the one commit between the two touches only `igneum_exportSegments`). Profile: `infra/fast-time/override-60x.json` with `skip_proof_of_work` (epoch 60 DAA, lead 10 DAA, weight window and `min_daa` 120 DAA, dust 5), three nodes on ports 30200 and up (network `igneum-devnet-1020`), n0 and n2 dialling n1 through proxies holding every byte 100 ms one way. Keys: h0 h1 on n0, h2 on n1, h3 on n2 at share 0.1375 each (55%); s0 on n1 and s1 on n2 at 0.225 each (45%); 1 block/s in all. Warm 230 s with everyone voting, then the silent pair restarted with `--no-vote` for 200 s (mining on, the same label-derived keys), then restarted voting for 150 s.
|
||||
|
||||
| measure | n0 | n1 | n2 |
|
||||
|---|---|---|---|
|
||||
| silent set's share of the window at the pause (2 keys, 57 of 120 blocks; window DAA 209) | 47.5% | 47.5% | 47.5% |
|
||||
| max locked index at the pause | 7 | 7 | 7 |
|
||||
| epoch index at the pause (DAA 231) | 3 | 3 | 3 |
|
||||
| epoch boundaries crossed during the pause (DAA 231 to 448: 240, 300, 360, 420) | 4 | 4 | 4 |
|
||||
| each new index first seen at its boundary DAA, inside one 2-s poll | yes | yes | yes |
|
||||
| new locks during the pause (index above 7) | 0 | 0 | 0 |
|
||||
| checkpoints proposed in the pause and not locked | 7 | 7 | 7 |
|
||||
| first new lock after the silent set voted again, s | 2 | 2 | 2 |
|
||||
| max locked index at the end of the heal window | 19 | 19 | 19 |
|
||||
| sink at the end | 2b2d0123ec | 2b2d0123ec | 2b2d0123ec |
|
||||
| conflicting certificates logged | 0 | 0 | 0 |
|
||||
| `PoW cache built` lines (one per dataset day, M30; two fast-time days in the run) | 2 | 2 | 2 |
|
||||
|
||||
Epoch boundaries as the template's `powEpoch` reported them (polled every 2 s; the template names the NEXT boundary; "DAA seen" is the node's virtual DAA at the poll that first showed the new index):
|
||||
|
||||
| epoch index | boundary at DAA | next boundary the template names | DAA seen n0 / n1 / n2 | s after the pause start | seed (same on all three) |
|
||||
|---|---|---|---|---|---|
|
||||
| 4 | 240 | 300 | 246 / 245 / 241 | 8.0 / 8.0 / 6.0 | db32303e55cd... |
|
||||
| 5 | 300 | 360 | 300 / 300 / 300 | 66.0 | 4071e7c0c759... |
|
||||
| 6 | 360 | 420 | 362 / 362 / 363 | 130.1 | 35179dfc4d2a... |
|
||||
| 7 | 420 | 480 | 420 / 421 / 423 | 178.1 | 0f9e517c1920... |
|
||||
| 8, 9, 10 (heal window) | 480, 540, 600 | | | | cc8df2e94d70..., c490c83025df..., ca74bb596ccc... (same on all three) |
|
||||
|
||||
Reading. With 52.5% of the window signing (under the 2/3 floor) no checkpoint locked for 217 DAA: 7 checkpoints sat proposed on every node. The program epoch advanced four times on schedule on all three nodes with one seed per index (the seed is the selected-chain block at the lead, certified or not, spec 4.3), so a finality pause does not stop or fork the lottery. All 12 (index, hash) locks the nodes held before the pause (indices 4 to 7 on each) were held unchanged by every node after the heal; locking resumed 2 s after the silent pair voted again and reached index 19; one sink on all three nodes; 0 conflicting certificates. Raw: `/tmp/igneum-fin-f20/results.{md,json}`, node logs `/tmp/igneum-fin-f20/n*/node.log`.
|
||||
|
||||
## 5 October 2026 (night), ledger close round 1: X12 the 3 October devnet run from its record
|
||||
|
||||
Source: `sim/difficulty/devnet-2026-10-03.csv` (3,682 headers pulled read-only from the observer node on 3 October, every header the observer saw to DAA 3,680) and `/tmp/igneum-devnet/node1.log` (node 1's log from 19:07 UTC 3 October to its stop at 09:05 UTC 4 October, 70,321 lines; 44,256 `PoW accepted` lines in all, 3,896 inside the record's span). Command: `python3 sim/difficulty/record_report.py devnet-2026-10-03.csv /tmp/igneum-devnet/node1.log` (new, this round; a file analysis, no lock). Times are UTC (the round-3 review quotes the same minutes in BST). The launcher log on the PC is not reachable tonight.
|
||||
|
||||
Block rate in 2-minute wall buckets (CSV headers; the node log's `PoW accepted` count per bucket agrees within 4 blocks everywhere):
|
||||
|
||||
| bucket (UTC) | headers | blocks/s | chain blocks | DAA at end | expected hashes per block at end | what was mining |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 19:07:49 to 19:27:49 (ten buckets) | 9 to 21 each, 137 in all | 0.07 to 0.17 | all | 136 | 134,217,727 (genesis) | the Mac's Metal worker alone |
|
||||
| 19:27:49 to 19:41:49 | 5 in all | 0.00 to 0.03 | | 141 | genesis | idle: the Metal worker stopped at 19:28 |
|
||||
| 19:41:49 to 19:55:49 (seven buckets) | 46, 65, 69, 74, 75, 64, 54 | 0.38 to 0.62 | 30 to 58 per bucket | 588 | genesis | the PC joined at 19:41 |
|
||||
| 19:55:49 to 19:57:49 | 341 | 2.84 | 136 | 925 | 11,758,225 | first retarget at DAA 600, 19:56:12 |
|
||||
| 19:57:49 to 19:59:49 | 622 | 5.18 | 181 | 1,545 | 8,569,314 | |
|
||||
| 19:59:49 to 20:01:49 | 690 | 5.75 | 202 | 2,234 | 9,222,852 | peak minute 355 headers from 19:59:49 |
|
||||
| 20:01:49 to 20:03:49 | 582 | 4.85 | 200 | 2,823 | 25,726,951 | |
|
||||
| 20:03:49 to 20:11:49 (four buckets) | 178, 200, 160, 198 | 1.33 to 1.67 | 84 to 107 | 3,556 | 29.9 M to 39.3 M | still 1.3x to 1.7x over target |
|
||||
| 20:11:49 to 20:13:49 | 42 | 0.35 | 25 | 3,599 | 36,856,148 | the epoch boundary |
|
||||
| 20:13:49 to 20:15:49 | 79 | 0.66 | 37 | 3,680 | 31,189,959 | epoch 1, end of the CSV |
|
||||
|
||||
Retarget trajectory on the selected chain (732 changes of bits over 1,655 chain blocks):
|
||||
|
||||
| DAA | UTC | expected hashes per block | step |
|
||||
|---|---|---|---|
|
||||
| 0 to 599 | 19:07:49 to 19:56:11 | 134,217,727 | genesis held for 600 blocks (Kaspa's 150-sample minimum) |
|
||||
| 600 | 19:56:12 | 28,007,856 | x4.79 easier at once |
|
||||
| 640 | 19:56:27 | 23,522,904 | easing 2% a block |
|
||||
| 715 | | 18,305,127 | |
|
||||
| 812 | 19:57:21 | 14,230,949 | |
|
||||
| 1,614 | 20:00:01 | 8,553,182 | the trough, x15.7 easier than genesis |
|
||||
| 2,750 | 20:03:26 | 13,347,799 | hardening |
|
||||
| 2,848 | 20:04:01 | 26,451,138 | |
|
||||
| 3,352 | 20:09:46 | 38,750,001 | the peak after the overshoot |
|
||||
| 3,652 | 20:15:20 | 31,153,119 | last change in the CSV |
|
||||
|
||||
Epoch boundary: last epoch-0 header at DAA 3,599 stamped 20:12:14, first epoch-1 header at DAA 3,602 stamped 20:14:54, gap 160.1 s by header timestamps; node 1's `PoW accepted` lines go silent for 160.9 s after DAA 3,599 and resume at 20:14:55 with the epoch-1 seed e03c1fde6790.... Longest other gaps: 526.5 s after DAA 139 (the idle period), 199.5 s after DAA 140, 151.4 s after DAA 141. Headers in minutes above 2 blocks/s: 2,235.
|
||||
|
||||
The brief's four numbers against this record:
|
||||
|
||||
| number in the brief | the record | verdict |
|
||||
|---|---|---|
|
||||
| a 50x hash-rate step when the PC joined | block rate 0.07 to 0.17 blocks/s before, 0.38 to 0.62 after, both at genesis difficulty, so the delivered hash rate stepped from about 10 to 23 MH/s to 51 to 83 MH/s (blocks/s x 134.2 M), a 3x to 8x step | not supported as 50x: the record sees only the chain's implied hash rate; a 50x step would be the PC's bench against the Metal worker's, which no file here holds |
|
||||
| a trough near 9 million | 8,553,182 expected hashes at DAA 1,614 (20:00:01) | supported |
|
||||
| 5.5 blocks a second | 5.18, 5.75 and 4.85 blocks/s in the three 2-minute buckets from 19:57:49; 355 in the peak minute (5.9/s) | supported |
|
||||
| two-thirds worker efficiency over eight processes | needs the launcher's summed status against the chain; the chain implies 51 to 83 MH/s against the 229 MH/s bench, 22% to 36% | not checkable here: the launcher log lives on the PC |
|
||||
|
||||
|
||||
## 5 October 2026 (night), ledger close round 1: M21 block propagation with bodies at the mass limit, k re-derived
|
||||
|
||||
Machine: Apple M5 Max, shared with other agents' run-slot jobs (`m25-rerun.sh`, `igneum-prove-host`, then `tools/proving-v1/net.mjs` and an NVRTC emulation test) and the live devnet nodes; the first pass overlapped the start of two of them. Command: `tools/lock/with-lock.sh run node tools/finality-attacks/m21.mjs` (new, worktree `igneum-wt-ledger-sims`), run slot run-1, 19:04 to 19:10 UTC, 375 s wall. Binaries: `target-036/release/igneumd` from `vendor/igneum-node-036` at `a24ab01a` (release-0.3.6, the live node's binary); no miner binary, the harness is the block producer. Profile: fast time with `skip_proof_of_work` and `max_coinbase_payload_len` raised to 600,000 (the file carries 16,384), three nodes on ports 30100 and up (network `igneum-devnet-1010`), n0 and n2 dialling n1 through proxies that hold every byte 100 ms one way and add no bandwidth limit (loopback). Bodies: `getBlockTemplate` with `extraData` of SIZE zero bytes, the nonce set to a sequence number, `submitBlock`; n0 at 0.7 and n2 at 0.3 blocks/s with Poisson gaps, 180 s per size. Every node's `blockAdded` notification is stamped by the harness process and matched to the submit time by nonce. The one body a producer can grow on this line without a transaction relay is the coinbase payload: the devnet-v4 line retired UTXO transactions from bodies, the EVM transactions ride in lanes the relay fills, the proof records are 274 B each and the SP1 proofs never enter a block (sweep round 6). The compute mass limit (500,000 at 1 mass per coinbase byte, `check_block_mass`) bounds the body near 500 KB, so 490,000 B of padding is the largest body the rules allow.
|
||||
|
||||
| padding (B) | coinbase payload (B) | blocks | seen on all 3 | own node p50 / p90 / p99 / max (ms) | 1 hop p50 / p90 / p99 / max (ms) | 2 hops p50 / p90 / p99 / max (ms) | second hop alone p50 / p90 / p99 / max (ms) | k from p50 / p90 / p99 / max (delta 0.01, 1 block/s) | blocks per node at the end | sinks |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 0 | 59 | 163 (0 rejected) | 163 | 6 / 31 / 127 / 386 | 318 / 461 / 775 / 1,144 | 626 / 824 / 1,093 / 2,099 | 309 / 398 / 464 / 955 | 4 / 5 / 6 / 10 | 163 / 163 / 163 | 1 |
|
||||
| 490,000 | 490,059 | 188 (0 rejected) | 188 | 16 / 33 / 64 / 118 | 329 / 378 / 485 / 606 | 641 / 696 / 812 / 857 | 311 / 324 / 343 / 351 | 5 / 5 / 5 / 5 | 351 / 351 / 351 | 1 |
|
||||
|
||||
k is the fork's `calculate_ghostdag_k(2 D, 0.01)` (`consensus/core/src/config/bps.rs`, ported into the script and checked: D 5 s gives 18) with D the two-hop time from submit to the far node's notification. Reading. A hop costs about three link traversals, not one: the relay is inv, request, block (`InvRelayBlock`, `RequestRelayBlocks`, `Block`), so 100 ms of one-way delay shows as 310 to 330 ms per hop at p50, and two hops as 626 to 641 ms. A 490 KB body added 11 ms at p50 and nothing at the tail (the baseline's tail, p99 1,093 and max 2,099 ms, fell in the minutes two other agents' jobs started; the big-body pass that followed had a tighter tail, p99 812 and max 857 ms, so the baseline tail is machine load, not body size). What the proxy does not charge is serialisation: 490 KB at 100 Mbit/s is 39 ms per hop and 78 ms over two, which lifts the measured p99 to 0.89 s (k 5) and the max to 0.94 s (k 6); on the cloud devnet's measured p99 of 0.67 s with today's bodies (4 October) the same addition gives 0.75 s, k 6. So k = 18 (D = 5 s) keeps 5.3x to 5.6x of headroom over the largest body the rules allow on this topology, against 7.5x measured on the cloud with 723-byte bodies. Not measured: the red rate under these bodies (the DAG had 351 blocks on every node and one sink; red counts need a per-block `getBlock` walk, not done). Raw: `/tmp/igneum-fin-m21/results.{md,json}`.
|
||||
|
||||
## 5 October 2026 (night), ledger close round 1: X14 concentration in hashing, signing, proving and aggregation from the Mac node's RPCs, and E16 one live block
|
||||
|
||||
Machine: the Mac observer node (`target-036/release/igneumd` at `a24ab01a`, wRPC `ws://127.0.0.1:28640`, exec RPC `http://127.0.0.1:26790`), read-only, nothing submitted. Command: `tools/lock/with-lock.sh run node tools/finality-attacks/x14-concentration.mjs` (new), run slot run-1, 19:12:39 UTC, 7 s; the console read with `node tools/console.mjs machines` at 19:14 UTC. Chain position: DAA 125,005, weights at checkpoint 4,071 (DAA 124,983), window 7,200 DAA (the devnet's 2-hour stand-in for 30 days), dust 5.
|
||||
|
||||
| quantity | source | keys | total | top-1 | top-3 | top-10 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| hashing (blue blocks per vote key in the window; 25 of 29 keys above dust) | `getFinalityWeights` | 29 | 6,734 blocks | 25.9% | 37.3% | 67.0% |
|
||||
| aggregation (certificates built per named aggregator key, 210 certified or locked checkpoints in the window, 23 anonymous) | `getFinalityCheckpoints.certificateAggregator` | 20 | 187 certificates | 17.1% | 43.3% | 85.0% |
|
||||
| aggregator sortition (keys named eligible per checkpoint, 225 checkpoints) | `getFinalityCheckpoints.aggregators` | 27 | 1,492 eligibilities | 7.1% | 20.4% | 61.9% |
|
||||
| proving (paid proof records per prover key hash, 4,474 chain blocks from DAA 117,784 to the tip; 275 records carried, 144 rejected) | `igneum_getSegment.proofRecords` | 1 | 131 paid shards | 100% | 100% | 100% |
|
||||
| proving by payout address | the same records | 1 | 131 paid shards | 100% | 100% | 100% |
|
||||
| signing per key | not exposed | | | | | |
|
||||
|
||||
Signing: no RPC on this line returns a certificate's signer set or bitmap (`RpcCheckpoint` in `rpc/core/src/model/finality.rs:183-200` carries `signedWeight`, `votesSeen`, `voters`, `aggregators`, `certificateAggregator`; the bitmap lives only inside the coinbase-carried certificate and the node's finality store). What is exposed over the 210 locked checkpoints: `signedWeight` over `totalWeight` p50 71.1%, min 38.8%, max 100%; `votesSeen` p50 16 of 25 voters. A locked checkpoint at 38.8% of total says the two fields are not the pair the lock test used (a lock needs 2/3 of total), so even the aggregate figure needs the certificate itself; O-X.1's observer extract stays owed for signing. Key-to-machine ratio: 29 keys in the window against 4 machines on the console at 19:14 UTC (Mac, PC 1, PC 2, Sam's Mac; the US laptop was not reporting), 7.3 keys per machine, or 5.8 against the 5 machines of the evening's X5 reading. The one proving key is PC 2's prover (`igneum_getProvingStatus`: 519 shards paid in all, 639.458 IGN; the observer's pool holds 94 records unverified because its verifier is off).
|
||||
|
||||
E16, one live chain block (chain block 81,217, hash 8d3bb5404e8eb3..., read at 19:12 UTC): on the EVM side (`igneum_getSegment`) it carries a valid proof record for block 81,197 shard 0 by key hash e809e39672ed32... paid 2.726179 IGN to 0xcafc6e743c6848d637ab6283db5ba9fa3023516a, its own `provingPoolCredit` is 1.818 IGN and its rewards list two miner addresses; on the UTXO side (`getBlock`) its one transaction, the coinbase (payload 677 B), has three outputs:
|
||||
|
||||
| output | sompi | script | meaning |
|
||||
|---|---|---|---|
|
||||
| 0 | 363,523,103 | 20 f63c07a5... ac (pay to pubkey) | a blue block's producer, 80% of its subsidy plus the rounding remainder |
|
||||
| 1 | 363,522,223 | 20 ea449125... ac | the second blue block's producer |
|
||||
| 2 | 181,761,330 | 6a 16 69676e65756d2d70726f76696e672d706f6f6c2d7630 | OP_RETURN `igneum-proving-pool-v0`: 20% of both subsidies, unspendable |
|
||||
|
||||
The three sum to 908,806,656 sompi, two blocks' subsidies; output 2 is 2 x 90,880,665, the 20% of each rounded down (`proving_pool_share`). So on the live chain the 20% is burned on the UTXO ledger and the same 20%, in wei, is credited to the EVM escrow by the executor; the payout to the prover came from that escrow, not from the coinbase. Raw: `/tmp/igneum-x14-results.md`.
|
||||
|
|
|
|||
|
|
@ -773,7 +773,7 @@ Evidence: design doc "The first six months".
|
|||
### X1. "Reproducible from the repository" and the repository is private
|
||||
"Your site links to github.com/igneum-network/igneum. It 404s. 'Every number above is measured, published, and reproducible from the repository' is false today."
|
||||
|
||||
Status: Conceded, fix now.
|
||||
Status: Fixed, logged (5 October 2026, night, ledger close round 1): bench-log "5 October 2026 (night), ledger close round 1: X12 the 3 October devnet run from its record"; the launcher half (the eight processes' summed status) stays open until the PC's log is read. Was: Conceded, fix now.
|
||||
|
||||
Answer: Correct. Either the repository goes public with the litepaper or the sentence and the GitHub link come off the site until January 2027. Publishing the bench logs, the simulator and the test report with the litepaper is the cheaper fix and the honest one.
|
||||
|
||||
|
|
@ -1214,12 +1214,14 @@ Evidence: the files above. Test: sync a fresh node from the 30-hour devnet. Revi
|
|||
### M21. GHOSTDAG k is Kaspa's table value for Kaspa-sized bodies
|
||||
"k = 18 assumes a 5-second delay bound measured with Kaspa's blocks. Yours carry EVM transactions and recursive proof records."
|
||||
|
||||
Status: Answered with evidence for today's bodies, Open for proof-bearing bodies (5 October 2026, evening sweep). Sweep (5 October 2026, evening): block sizes measured on the live devnet and k derived from them. The last 60 chain blocks at DAA 112,433 (Mac node, wRPC, sizes summed from the RPC fields, approximate serialisation): p50 723 bytes, p90 1,022, max 6,908 (a block carrying a certificate section), 1 transaction per block (the coinbase), coinbase payload p50 395 bytes, max 6,580; the proof records in the coinbase are 274 bytes each (unit test `largest_coinbase_fits_on_every_network`), the SP1 proof itself (1.27 MB per shard, bench-log 5 October) stays in the pool and never enters a block. Serialisation of the largest observed block on a 100 Mbit/s link is 0.6 ms per hop, so the delay bound is the propagation alone: with the fork's `calculate_ghostdag_k` (delta 0.01, x = 2 D at 1 block/s) the cloud devnet's p50 343 ms gives k 3, p90 497 ms k 4, p99 666 ms k 5, max 2.3 s k 10, and k = 18 corresponds to D = 5 s, 7.5x the measured p99. For mainnet-sized bodies: the fast-time profile's mass limits (500,000 compute mass at 1 mass per transaction byte) bound a body near 500 KB, 40 ms per hop at 100 Mbit/s and about 120 ms over the 3 hops of the cloud topology, which moves the p99 to about 0.8 s and k to 6, still 3x inside k = 18; a 5-s bound holds bodies up to about 50 MB per hop at that bandwidth. So k = 18 is Kaspa's value and it carries 3x to 7x of headroom on the measured network for any body the mass limits allow; the proof-bearing run of O-2.2 decides whether the red rate under real bodies stays near the model (bench-log "FUD ledger sweep round 6", M21). Was: Open, extends O-2.2. Sweep (5 October 2026): k re-derived with the fork's own function (`vendor/rusty-kaspa/consensus/core/src/config/bps.rs`, `calculate_ghostdag_k`, delta 0.01) at 1 block/s: a delay bound of 2 s gives k 9, 5 s gives 18, 10 s gives 31, 20 s gives 55, 30 s gives 79. Measured propagation on the 12-node, 5-region cloud devnet of 4 October with today's bodies: p99 0.67 s, max 2.3 s, which the function maps to k 5 and k 10. So k = 18 carries about 7x headroom on today's bodies; the run with proof-bearing bodies (O-2.2) is still owed and decides whether that headroom survives.
|
||||
Status: Answered with evidence for the largest body the rules allow (5 October 2026, night, ledger close round 1: 490 KB coinbase bodies on the fast-time 3-node network with 100-ms proxied links, k re-derived with the fork's function, bench-log "5 October 2026 (night), ledger close round 1: M21 block propagation with bodies at the mass limit, k re-derived"); the red rate under such bodies is not measured. Was: Answered with evidence for today's bodies, Open for proof-bearing bodies (5 October 2026, evening sweep). Sweep (5 October 2026, evening): block sizes measured on the live devnet and k derived from them. The last 60 chain blocks at DAA 112,433 (Mac node, wRPC, sizes summed from the RPC fields, approximate serialisation): p50 723 bytes, p90 1,022, max 6,908 (a block carrying a certificate section), 1 transaction per block (the coinbase), coinbase payload p50 395 bytes, max 6,580; the proof records in the coinbase are 274 bytes each (unit test `largest_coinbase_fits_on_every_network`), the SP1 proof itself (1.27 MB per shard, bench-log 5 October) stays in the pool and never enters a block. Serialisation of the largest observed block on a 100 Mbit/s link is 0.6 ms per hop, so the delay bound is the propagation alone: with the fork's `calculate_ghostdag_k` (delta 0.01, x = 2 D at 1 block/s) the cloud devnet's p50 343 ms gives k 3, p90 497 ms k 4, p99 666 ms k 5, max 2.3 s k 10, and k = 18 corresponds to D = 5 s, 7.5x the measured p99. For mainnet-sized bodies: the fast-time profile's mass limits (500,000 compute mass at 1 mass per transaction byte) bound a body near 500 KB, 40 ms per hop at 100 Mbit/s and about 120 ms over the 3 hops of the cloud topology, which moves the p99 to about 0.8 s and k to 6, still 3x inside k = 18; a 5-s bound holds bodies up to about 50 MB per hop at that bandwidth. So k = 18 is Kaspa's value and it carries 3x to 7x of headroom on the measured network for any body the mass limits allow; the proof-bearing run of O-2.2 decides whether the red rate under real bodies stays near the model (bench-log "FUD ledger sweep round 6", M21). Was: Open, extends O-2.2. Sweep (5 October 2026): k re-derived with the fork's own function (`vendor/rusty-kaspa/consensus/core/src/config/bps.rs`, `calculate_ghostdag_k`, delta 0.01) at 1 block/s: a delay bound of 2 s gives k 9, 5 s gives 18, 10 s gives 31, 20 s gives 55, 30 s gives 79. Measured propagation on the 12-node, 5-region cloud devnet of 4 October with today's bodies: p99 0.67 s, max 2.3 s, which the function maps to k 5 and k 10. So k = 18 carries about 7x headroom on today's bodies; the run with proof-bearing bodies (O-2.2) is still owed and decides whether that headroom survives.
|
||||
|
||||
Answer: Correct. Spec 2.1 takes k, max parents and the mergeset limit from Kaspa's table at 1 BPS. O-2.2's two-miner devnet measures the parallel and red rate; it should run with proof-bearing bodies of the size section 5.4 of the execution design implies, and k should be re-derived from the measured delay.
|
||||
|
||||
Evidence: spec 2.1; `docs/design/execution-layer.md` 5.4. Review id R3.4.
|
||||
|
||||
Run (5 October 2026, night): `tools/lock/with-lock.sh run node tools/finality-attacks/m21.mjs` (new; the live node line `target-036` at `a24ab01a`, fast time, three nodes, proxies holding every byte 100 ms one way, no bandwidth limit, `max_coinbase_payload_len` 600,000 in the override), 375 s wall. The harness produced the blocks itself (`getBlockTemplate` with 0 and then 490,000 zero bytes of `extraData`, 180 s per size, 0.7 blocks/s on n0 and 0.3 on n2) and stamped every node's `blockAdded` notification; 490,000 B of padding is the largest body the 500,000 compute-mass limit allows (one mass per coinbase byte), and proof-bearing bodies larger than today's do not exist on this line (records 274 B, proofs never in a block). Two-hop propagation p50 / p90 / p99 / max: 626 / 824 / 1,093 / 2,099 ms with 59-byte payloads (163 blocks) and 641 / 696 / 812 / 857 ms with 490,059-byte payloads (188 blocks); one hop 318 and 329 ms at p50 (the relay is inv, request, block: three link traversals per hop); own-node processing 6 and 16 ms at p50. k from the fork's `calculate_ghostdag_k(2 D, 0.01)`: 5 from the big-body p99 (6 from the baseline's p99, whose tail fell under two other agents' jobs starting), 6 with 39 ms per hop of 100 Mbit/s serialisation added to the max. So the largest body the rules allow moves the delay bound by about 1% at p50 on emulated links and k = 18 keeps 5.3x to 5.6x of headroom here, against 7.5x on the cloud devnet with 723-byte bodies. Not measured: the red rate (one sink and 351 blocks on every node; red counts need a per-block walk). Bench-log heading: "5 October 2026 (night), ledger close round 1: M21 block propagation with bodies at the mass limit, k re-derived".
|
||||
|
||||
### F14. Weight in blocks over a window in blocks under a lagging retarget
|
||||
"Your day counts assume the block supply is capped at one a second. It is not during a retarget lag, and weight is counted in blocks."
|
||||
|
||||
|
|
@ -1413,6 +1415,8 @@ Evidence: `/tmp/igneum-devnet/node1.log`, `sim/difficulty/devnet-2026-10-03.csv`
|
|||
|
||||
Added by `docs/review/external-2026-10-03.md`, which holds the reviewer's text verbatim and the point-by-point classification (13 already answered, 15 new, 1 wrong). The reviewer is a general-purpose AI assistant; its claims about third parties are approximate. Entries are placed under their section letters and numbered on from the last entry of each section. Count after this block: 122 entries (107 plus 15). Every entry is Open with its experiment or decision named.
|
||||
|
||||
Run (5 October 2026, night): `python3 sim/difficulty/record_report.py devnet-2026-10-03.csv /tmp/igneum-devnet/node1.log` (new, a file analysis). From the CSV (3,682 headers to DAA 3,680, 1,655 chain blocks) and node 1's log (3,896 `PoW accepted` lines inside the span, within 4 of the CSV in every 2-minute bucket): the step profile is 0.07 to 0.17 blocks/s with the Metal worker alone (19:08 to 19:28 UTC), 5 headers in the idle 14 minutes, 0.38 to 0.62 blocks/s after the PC joined at 19:41, all at the genesis 134,217,727 expected hashes; the retarget at DAA 600 (19:56:12) eased x4.79 at once to 28.0 M, 14.2 M at DAA 812, the trough 8,553,182 at DAA 1,614 (20:00:01, x15.7 easier than genesis), back to 26.5 M by DAA 2,848 and a 38.75 M peak at DAA 3,352; the 2-minute buckets peaked at 2.84, 5.18, 5.75 and 4.85 blocks/s from 19:55:49 (355 headers in the peak minute) and sat at 1.3 to 1.7 blocks/s for the next eight minutes; the epoch boundary gap is 160.1 s by header timestamps (DAA 3,599 at 20:12:14 to DAA 3,602 at 20:14:54) and 160.9 s of silence in node 1's log. Of the brief's numbers the record supports the trough near 9 million (8.55 M) and 5.5 blocks a second (5.2 to 5.75 per 2-minute bucket); it does not support a 50x step (the chain's implied hash rate stepped 3x to 8x, from 10 to 23 MH/s to 51 to 83 MH/s, and no file here holds the PC's bench against the Metal worker's) and it cannot check two-thirds efficiency over eight processes (the chain implies 22% to 36% of the 229 MH/s bench; the launcher's summed status is on the PC, unreachable tonight). Bench-log heading: "5 October 2026 (night), ledger close round 1: X12 the 3 October devnet run from its record".
|
||||
|
||||
### M22. The ASIC challenge has no scoring rules, and 2x is not the economic line
|
||||
"Your bounty says 'beats a GPU by more than 2x'. Per what? Hashes per second, per joule, per dollar, on the average program or the worst hour? A chip at 1.6x with half the capital cost wins. And nobody has published eligible hardware, funding or a judge."
|
||||
|
||||
|
|
@ -1434,12 +1438,14 @@ Evidence: spec 3.1 W5 and W6, 3.6; `sim/results_v2.md` (renter scenarios only).
|
|||
### F20. During a finality pause the program must keep advancing, and nothing says which guarantees survive
|
||||
"Your epoch seed is a VDF of a certified checkpoint. When finality pauses for four days, your own 50% churn figure, which checkpoint seeds hour 50? And when the pause ends, what exactly was still guaranteed in between?"
|
||||
|
||||
Status: Open, decision scheduled at gate 3 (O-3.16, with O-4.3). Sweep (5 October 2026): the seed half is decided (spec 4.3, from O-4.3: the seed checkpoint is the selected-chain block at the lead's blue score, certified or not, so mining never waits for a certificate) and spec 06 marks O-3.16's text closed; the devnet test through three epoch boundaries under a 45% silent set is still owed (devnet, person).
|
||||
Status: Answered with evidence for the test half (5 October 2026, night, ledger close round 1: the fast-time run through four epoch boundaries under a 47.5% silent set, bench-log "5 October 2026 (night), ledger close round 1: F20 finality pause under a 45% silent set through four epoch boundaries"); the gate-3 wording of the four guarantees (O-3.16, O-4.3) stays a decision for the project lead. Was: Open, decision scheduled at gate 3 (O-3.16, with O-4.3). Sweep (5 October 2026): the seed half is decided (spec 4.3, from O-4.3: the seed checkpoint is the selected-chain block at the lead's blue score, certified or not, so mining never waits for a certificate) and spec 06 marks O-3.16's text closed; the devnet test through three epoch boundaries under a 45% silent set is still owed (devnet, person).
|
||||
|
||||
Answer: Correct on the gap. Spec 4.3 proposes that the seed checkpoint may be the uncertified selected-chain block at the checkpoint blue score, so a pause does not stop mining, and O-4.3 leaves the decision to gate 3; the design document still says an epoch cannot start without a valid proof. No text states what holds during a pause: that blocks, execution and proofs continue (3.7 item 2 says proof of work in practice), that every lock before the pause stands (3.5), that `finality_active` is false and exchanges wait on the 12-hour finality depth (3.9), and that the seed pipeline advances from uncertified checkpoint blocks with the reorg exposure that implies (a 1,200-DAA-s lead plus d). Gate 3 takes the uncertified option or states the stop; this ledger recommends uncertified, as spec 4.3 does, and the four guarantees go into spec 3.7. Test: the devnet with the finality module stalled by a 45% silent set (3.3.1 row C) through at least 3 epoch boundaries, recording that the program advanced on schedule, that no node forked on the seed, and that the pre-pause locks survived the heal.
|
||||
|
||||
Evidence: spec 4.3 (O-4.3), 3.3.1, 3.5, 3.7 item 2, 3.9. Experiment: O-3.16. Review: external, point 2.
|
||||
|
||||
Run (5 October 2026, night): `tools/lock/with-lock.sh run node tools/finality-attacks/f20.mjs` (new scenario file; fast-time 3-node network, 100-ms proxied links, the live node line `target-036` at `a24ab01a`), 592 s wall. Six keys at 1 block/s; after 230 s of warm-up (locks to index 7 on every node) the two keys holding 47.5% of the 120-DAA window (57 of 120 blocks) were restarted mining without votes for 200 s (DAA 231 to 448), then voting again for 150 s. During the pause: 0 new locks on any node and 7 checkpoints left proposed (52.5% signing is under the 2/3 floor, so finality paused as the rule says); the program epoch advanced at DAA 240, 300, 360 and 420 on all three nodes, each new index first seen inside one 2-s poll of its boundary, with one seed per index on all three (seeds db32303e55cd..., 4071e7c0c759..., 35179dfc4d2a..., 0f9e517c1920...); the three sinks agreed at the end. After the heal: locking resumed 2 s after the silent pair voted again and reached index 19; all 12 (index, hash) locks held before the pause were held unchanged by every node; 0 conflicting certificates. So on this line a finality pause stops certificates and nothing else: blocks, the hourly program and the pre-pause locks all carry through, which is the uncertified-seed option of spec 4.3 measured. Bench-log heading: "5 October 2026 (night), ledger close round 1: F20 finality pause under a 45% silent set through four epoch boundaries".
|
||||
|
||||
### F22. Certificates carry 8 to 10 of 12 votes on a healthy network, so locks sit a hair above the floor
|
||||
"Two of your cloud checkpoints locked at 66.8% against a 66.7% floor with every node connected. One slow aggregator and finality pauses."
|
||||
|
||||
|
|
@ -1463,12 +1469,14 @@ Evidence: P1; execution-layer 9.1 R2 and R4; `docs/bench-log.md` (no shard has b
|
|||
### P17. Interfaces must show four states, and the design shows three
|
||||
"Included, executed, proven, finalised are four different facts. Your status call returns executed, proven, locked and a list of blocks. A wallet that shows a balance at 'included' is lying by omission."
|
||||
|
||||
Status: Open, rule written (3 October 2026, night) in `docs/design/execution-layer.md` 2.4; experiment scheduled (O-7.2). Sweep (5 October 2026): the conformance set is devnet work; not run.
|
||||
Status: Answered with evidence for what the RPC returns today (5 October 2026, night, ledger close round 1: report only, no code change); the four-state word in the response and the conformance run (O-7.2) are round 2. Was: Open, rule written (3 October 2026, night) in `docs/design/execution-layer.md` 2.4; experiment scheduled (O-7.2). Sweep (5 October 2026): the conformance set is devnet work; not run.
|
||||
|
||||
Answer: Correct. Design 2.3 defines executed, proven and locked; `igneum_getTransactionStatus` carries `included_in` as a list and never as a state; the phone app (phone-app 3 and 9) shows three words. A transaction in a block not yet on a selected chain, or in a merged block waiting its turn in the sequence, is included and nothing more, and on a DAG that gap is routine. The rule now in 2.4: every RPC, wallet, explorer and app reports exactly one of included, executed, proven, finalised (the user-facing word for locked), never a stronger word than the chain's own state, and shows "finality not active" in place of finalised while `finality_active` is false (3.9). Proven says nothing about availability: full blocks are what every full node holds (F13) and a light client takes data from nodes under spec 10.1. Test: a wallet and RPC conformance set on the devnet, one transaction through each state and the three failure paths (skipped, reorged out, finality paused).
|
||||
|
||||
Evidence: execution-layer 2.3, 2.4, 8.2; phone-app 3 and 9; spec 3.9, 10.1. Experiment: O-7.2. Review: external, point 2.
|
||||
|
||||
Run (5 October 2026, night, report only, no bench-log entry because nothing ran): on the running fork line (`vendor/igneum-node-036`, release-0.3.6 at `a24ab01a`, the binary node 1 runs) `igneum_getTransactionStatus` (`igneum/exec/src/rpc.rs:739-751`) returns one object: `includedIn`, a list with one entry per block that carries the transaction (block hash, `chainBlockNumber`, `executed`, `skipReason`); `executingCopy`, the block whose copy executed, or null; `executed`, true once the hash is in the executor's index; `proven: false` and `locked: false`, both constants in the source, never true on this line whatever proof records or certificates the chain holds; and `inMempool`. The states a caller can tell apart are therefore: in the mempool only (`inMempool` true, `includedIn` empty); included and not executed (`includedIn` non-empty, `executed` false: a block off the selected chain, or a merged block waiting its turn in the sequence); executed (`executed` true, `executingCopy` set); skipped (an `includedIn` entry carrying `skipReason`, `executed` false). Proven and finalised cannot be read from this call at all. The block tags of the EVM calls resolve in `resolve_block` (`rpc.rs:251-262`): the arm at line 257 maps `latest`, `pending`, `safe` and `finalized` all to the tip, and the comment at lines 226 to 228 says no certified checkpoint exists yet and the RPC must not pretend otherwise. That comment predates the first live lock (bench-log 4 October 2026, checkpoint 242), so today `eth_getBlockByNumber("finalized")` returns the tip of a chain that holds locked checkpoints below it: the tag overstates. Round 2 (code): the one-word state (included, executed, proven, finalised, or `finality not active`) in the response, `finalized` bound to the last locked checkpoint's chain block, then the O-7.2 conformance run.
|
||||
|
||||
### E12. Selfish operators under a price shock
|
||||
"Outside proving pays ten times more and IGN halves in a week. Every rational operator leaves hashing for jobs. Do your internal proofs go unproven, do queues grow, does anything bring them back? Assume nobody runs your client's scheduler."
|
||||
|
||||
|
|
@ -1526,12 +1534,14 @@ Evidence: `site/journey.json` phases 4 and 5; `docs/commercial/prover-customer-b
|
|||
### X14. Concentration is unmeasured in four places
|
||||
"Define independent for the 1,000-miner gate, then report concentration in hashing, checkpoint signing, proving and aggregation, because a thousand miners behind two pools is two."
|
||||
|
||||
Status: Open, measurement scheduled (O-X.1); extends X5. Sweep (5 October 2026): hashing concentration computed from the devnet record (`sim/difficulty/records/live-2026-10-04.csv`, 8,090 blocks, 18 vote keys): top-1 12.8%, top-3 34.5%, top-9 98.0%, the nine being devnet v4's nine identities run by three machines (approximate), so by machine the devnet is three parties. Signing, proving and aggregation concentration need certificate and proof-record extracts the observer does not keep yet (O-X.1).
|
||||
Status: Answered with evidence for hashing, aggregation and proving, Open for signing (5 October 2026, night, ledger close round 1: no RPC exposes a certificate's signer set, so the signing concentration needs the observer extract of O-X.1; bench-log "5 October 2026 (night), ledger close round 1: X14 concentration in hashing, signing, proving and aggregation from the Mac node's RPCs, and E16 one live block"); the independence definition stays the project lead's (X5). Was: Open, measurement scheduled (O-X.1); extends X5. Sweep (5 October 2026): hashing concentration computed from the devnet record (`sim/difficulty/records/live-2026-10-04.csv`, 8,090 blocks, 18 vote keys): top-1 12.8%, top-3 34.5%, top-9 98.0%, the nine being devnet v4's nine identities run by three machines (approximate), so by machine the devnet is three parties. Signing, proving and aggregation concentration need certificate and proof-record extracts the observer does not keep yet (O-X.1).
|
||||
|
||||
Answer: Correct. X5 conceded that "independent" needs a measurable definition (distinct ASNs, benchmark hardware fingerprints, pool attestations) and left it to phase 4. The four concentrations can each be computed from chain data: blue blocks per vote key and per pool (hashing), signed weight per key in certificates (checkpoint signing), proof records per prover key (proving, once P12's fix puts the key in the statement), and certificates and proof records per aggregator key (aggregation). The gate reports all four as top-1, top-3 and top-10 shares over 30 days, beside the independence count, on the live page. Transaction choice inside pools is a separate measurement and is already scheduled: whether members of the reference pool use declared templates (spec 9.4.2, mode C) is recorded under O-9.5.
|
||||
|
||||
Evidence: X5, F10, P12, spec 9.4.2. Experiment: O-X.1. Review: external, point 6.
|
||||
|
||||
Run (5 October 2026, night): `tools/lock/with-lock.sh run node tools/finality-attacks/x14-concentration.mjs` (new; the Mac observer node's wRPC and exec RPC, read-only, 7 s) at DAA 125,005, window 7,200 DAA, plus `node tools/console.mjs machines`. Hashing (`getFinalityWeights`, 29 keys, 25 above dust, 6,734 blocks): top-1 25.9%, top-3 37.3%, top-10 67.0%. Aggregation (`certificateAggregator` over 210 certified or locked checkpoints, 187 named, 23 anonymous, 20 keys): top-1 17.1%, top-3 43.3%, top-10 85.0%; sortition eligibility (1,492 namings over 225 checkpoints, 27 keys): top-1 7.1%, top-3 20.4%, top-10 61.9%. Proving (`igneum_getSegment.proofRecords` over 4,474 chain blocks, 275 records carried, 131 paid, 144 rejected): one key and one payout address hold 100% of the paid shards (PC 2's prover; 519 shards paid on the chain in all). Signing: NOT exposed; `RpcCheckpoint` carries `signedWeight`, `votesSeen`, `voters`, `aggregators` and `certificateAggregator` and no signer set or bitmap, so per-key signing concentration cannot be computed from any RPC on this line; what is exposed over the 210 locked checkpoints is `signedWeight` over `totalWeight` p50 71.1%, min 38.8%, max 100% (a locked checkpoint at 38.8% shows the two fields are not the pair the lock test used) and `votesSeen` p50 16 of 25. Key-to-machine ratio: 29 keys against 4 machines on the console at 19:14 UTC (5.8 to 7.3 keys per machine, depending on whether the evening's fifth machine is counted). Bench-log heading: "5 October 2026 (night), ledger close round 1: X14 concentration in hashing, signing, proving and aggregation from the Mac node's RPCs, and E16 one live block".
|
||||
|
||||
### X15. Remove the founders from a test network and show what continues
|
||||
"'The chain runs without its founders' is a sentence. Take the team's miners, provers, aggregators, seed nodes, observer and site off a running testnet and show what keeps producing blocks, proofs and locks."
|
||||
|
||||
|
|
@ -1921,12 +1931,14 @@ Evidence: the files above. Experiment: time from worker restart to first accepte
|
|||
### E16. The 20% pool is burned on the live chain, and the text says it pays provers
|
||||
"Your coinbase sends the 20% to an OP_RETURN tagged `igneum-proving-pool-v0`. Your own comment says provably burned. Your cap test counts it as supply. Your litepaper says, present tense, that it pays a standing prover population."
|
||||
|
||||
Status: Open (4 October 2026). Sweep (5 October 2026): confirmed in code: `devnet-v4` `consensus/core/src/igneum.rs:93` burns the 20% under the tag `igneum-proving-pool-v0`; the `proving` branch pays `proving_pool_credit` from shard records (`igneum/exec/src/proving.rs:304`) and is not on the devnet. The live-block test needs the proving branch rolled out; the text belongs to the testnet-prep branch.
|
||||
Status: Answered with evidence for the code half (5 October 2026, night, ledger close round 1: the 20% is burned on the UTXO ledger and paid on the EVM ledger from an escrow the executor credits by rule with the same 20%; one live block shows both; bench-log "5 October 2026 (night), ledger close round 1: X14 concentration in hashing, signing, proving and aggregation from the Mac node's RPCs, and E16 one live block"); the litepaper sentence is the text agent's (group A). Was: Open (4 October 2026). Sweep (5 October 2026): confirmed in code: `devnet-v4` `consensus/core/src/igneum.rs:93` burns the 20% under the tag `igneum-proving-pool-v0`; the `proving` branch pays `proving_pool_credit` from shard records (`igneum/exec/src/proving.rs:304`) and is not on the devnet. The live-block test needs the proving branch rolled out; the text belongs to the testnet-prep branch.
|
||||
|
||||
Answer: Correct for the live devnet-v4 line. `coinbase.rs:112-113`; `consensus/core/src/igneum.rs:86-98, 329-333`; evidence row 21. Proving v0 on the `proving` branch carries payouts in the shard statement (P21, P22) and the live chain does not run it. Until it does, a prover earns 0 from the pool and the spendable cap is 3.2 billion. Fix: one sentence in the litepaper Economics table (`site/litepaper.html:396, 414`) and under the homepage bar (`site/index.html:306, 446-448`): burned until the payout ships, no prover paid today; a burned-so-far figure on the live page; a decision on whether the payout reclaims the burned share or pays from new emission. Review ids R4.7.1, R4.7.2.
|
||||
|
||||
Evidence: the files above; `docs/review/round-4-2026-10-04.md` section 7. Experiment: one live devnet block whose pool output reaches a named prover key.
|
||||
|
||||
Run (5 October 2026, night, code half): on release-0.3.6 (`vendor/igneum-node-036` at `a24ab01a`, the binary every live node runs) `consensus/core/src/igneum.rs:79-101` still splits the subsidy 80/20 (`proving_pool_share` at 79, `producer_share` at 84) and builds the 20% output as OP_RETURN `igneum-proving-pool-v0` (`proving_pool_script_public_key` at 91, comment at lines 88-90: "provably burned on devnet v0"); the coinbase builder (`consensus/src/processes/coinbase.rs`) emits it in every block. The execution layer applies the subsidy a second time as a state transition (`igneum/exec/src/executor.rs:160-176`): for every blue block of the segment, 80% in wei to the block's miner address and 20% to `PROVING_POOL_ADDRESS` (0x...0220, `igneum/exec/src/config.rs:50`), summed into the segment's `proving_pool_credit`; `igneum/exec/src/proving.rs:306` reads that credit when a carried record is checked, and `carried_payouts` (`proving.rs:323-346`) pays the first valid record per (segment, shard) its share of that credit from the escrow to the record's payout address once `cfg.active_at` holds (activation DAA 84,100 on the live devnet, `igneum_getProvingStatus`). So the credit is funded by new issuance on the EVM ledger, not by the coinbase: the UTXO 20% is burned and the EVM 20% is minted into the escrow and paid out. Live block (read-only, 19:12 UTC): chain block 81,217 carries a valid record for block 81,197 shard 0 paid 2.726179 IGN to 0xcafc6e74... from the escrow (its own segment credit 1.818 IGN), while the same block's coinbase has outputs 363,523,103 and 363,522,223 sompi to the two producers and 181,761,330 sompi to `6a16 igneum-proving-pool-v0`, exactly 20% of both subsidies rounded down. Truth for the ledger: on the live chain the coinbase's 20% is still burned under the tag; provers are paid, 519 shards and 639.458 IGN so far, from the EVM escrow the executor credits with the same 20% in wei; a cap test that counts the UTXO burn as supply or the EVM credit as a transfer from the coinbase is wrong on one ledger or the other. Bench-log heading: "5 October 2026 (night), ledger close round 1: X14 concentration in hashing, signing, proving and aggregation from the Mac node's RPCs, and E16 one live block".
|
||||
|
||||
### L9. "100% to miners and provers", "0% anyone else", and no word that devnet coins have no value
|
||||
"The homepage says 100% to miners and provers and 0% to anyone else, beside a 1% client dev fee disclosed only in the litepaper. Nowhere on the site does it say the devnet's coins have no value or that the chain may be reset, and the Get the miner button is live."
|
||||
|
||||
|
|
|
|||
126
sim/difficulty/record_report.py
Normal file
126
sim/difficulty/record_report.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ledger X12 (5 October 2026, night): the 3 October devnet run's numbers from its record, for docs/bench-log.md.
|
||||
|
||||
python3 record_report.py [devnet-2026-10-03.csv] [/tmp/igneum-devnet/node1.log]
|
||||
|
||||
Prints, from the CSV (every header the observer saw, read-only, see README.md): the step profile (blocks per second
|
||||
in 2-minute wall buckets with the DAA range of each), the retarget trajectory (expected hashes per block by DAA at
|
||||
every change of `bits` on the selected chain, with the first retarget and the trough), and the epoch-boundary gap
|
||||
(the wall time between the last block of epoch 0 and the first block of epoch 1). From the node log, when present:
|
||||
accepted-block lines per 2-minute bucket over the same span, and the longest silence around the boundary.
|
||||
"""
|
||||
import csv
|
||||
import datetime as dt
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
CSV = sys.argv[1] if len(sys.argv) > 1 else "devnet-2026-10-03.csv"
|
||||
LOG = sys.argv[2] if len(sys.argv) > 2 else "/tmp/igneum-devnet/node1.log"
|
||||
|
||||
|
||||
def utc(ms):
|
||||
return dt.datetime.utcfromtimestamp(ms / 1000).strftime("%H:%M:%S")
|
||||
|
||||
|
||||
rows = list(csv.DictReader(open(CSV)))
|
||||
for r in rows:
|
||||
r["daa"] = int(r["daa_score"]); r["t"] = int(r["timestamp_ms"]); r["d"] = int(r["difficulty"]); r["chain"] = r["is_chain_block"] == "1"
|
||||
r["epoch"] = int(r["epoch"]); r["bs"] = int(r["blue_score"])
|
||||
rows.sort(key=lambda r: (r["t"], r["daa"]))
|
||||
genesis = rows[0]
|
||||
body = [r for r in rows if r["daa"] > 0 or r["bs"] > 0]
|
||||
t0 = body[0]["t"]
|
||||
print("# devnet-2026-10-03.csv: %d headers (genesis stamped %s UTC; first mined block %s, last %s UTC, DAA %d to %d, blue score to %d, %d chain blocks, %d epochs)" % (
|
||||
len(rows), utc(genesis["t"]), utc(body[0]["t"]), utc(body[-1]["t"]), body[0]["daa"], body[-1]["daa"], body[-1]["bs"], sum(1 for r in body if r["chain"]), len(set(r["epoch"] for r in body))))
|
||||
print()
|
||||
# 2-minute buckets
|
||||
B = 120_000
|
||||
buckets = defaultdict(list)
|
||||
for r in body:
|
||||
buckets[(r["t"] - t0) // B].append(r)
|
||||
print("## Block rate in 2-minute wall buckets (all headers, then chain blocks only)")
|
||||
print()
|
||||
print("| bucket (UTC) | blocks | blocks/s | chain blocks | DAA at end | difficulty at end (expected hashes) | epoch |")
|
||||
print("|---|---|---|---|---|---|---|")
|
||||
last_daa = 0
|
||||
for k in range(0, max(buckets) + 1):
|
||||
bs = buckets.get(k, [])
|
||||
if not bs:
|
||||
print("| %s to %s | 0 | 0.00 | 0 | %d | | |" % (utc(t0 + k * B), utc(t0 + (k + 1) * B), last_daa)); continue
|
||||
last = max(bs, key=lambda r: r["daa"]); last_daa = last["daa"]
|
||||
print("| %s to %s | %d | %.2f | %d | %d | %s | %d |" % (utc(t0 + k * B), utc(t0 + (k + 1) * B), len(bs), len(bs) / 120.0, sum(1 for r in bs if r["chain"]), last["daa"], format(last["d"], ","), last["epoch"]))
|
||||
print()
|
||||
# retarget trajectory on the selected chain
|
||||
print("## Retarget trajectory (selected chain, every change of bits)")
|
||||
print()
|
||||
print("| DAA | UTC | bits | expected hashes per block | change |")
|
||||
print("|---|---|---|---|---|")
|
||||
chain = [r for r in body if r["chain"]]
|
||||
chain.sort(key=lambda r: r["daa"])
|
||||
prev = None
|
||||
changes = 0
|
||||
trough = min(chain, key=lambda r: r["d"])
|
||||
for r in chain:
|
||||
if prev is None or r["bits"] != prev["bits"]:
|
||||
changes += 1
|
||||
if changes <= 12 or r is trough or r["daa"] in (715, 812) or (prev and r["d"] > prev["d"] and changes % 25 == 0):
|
||||
print("| %d | %s | %s | %s | %s |" % (r["daa"], utc(r["t"]), r["bits"], format(r["d"], ","), ("x%.2f easier" % (prev["d"] / r["d"]) if prev and r["d"] < prev["d"] else ("x%.2f harder" % (r["d"] / prev["d"]) if prev else "genesis"))))
|
||||
prev = r
|
||||
by_daa = {r["daa"]: r for r in chain}
|
||||
first = next((r for r in chain if r["bits"] != genesis["bits"]), None)
|
||||
print()
|
||||
print("Retargets on the chain: %d changes of bits over %d chain blocks. First retarget: DAA %s at %s UTC, %s to %s expected hashes (x%.2f easier). Trough: DAA %d at %s UTC, %s expected hashes (x%.1f easier than genesis). At DAA 715: %s; at DAA 812: %s; at the last chain block (DAA %d): %s." % (
|
||||
changes, len(chain), first["daa"] if first else "none", utc(first["t"]) if first else "", format(genesis["d"], ","), format(first["d"], ",") if first else "", genesis["d"] / first["d"] if first else 0,
|
||||
trough["daa"], utc(trough["t"]), format(trough["d"], ","), genesis["d"] / trough["d"], format(by_daa[715]["d"], ",") if 715 in by_daa else "n/a", format(by_daa[812]["d"], ",") if 812 in by_daa else "n/a", chain[-1]["daa"], format(chain[-1]["d"], ",")))
|
||||
print()
|
||||
# epoch boundary gap
|
||||
e0 = [r for r in body if r["epoch"] == 0]; e1 = [r for r in body if r["epoch"] == 1]
|
||||
if e0 and e1:
|
||||
a = max(e0, key=lambda r: r["t"]); b = min(e1, key=lambda r: r["t"])
|
||||
print("Epoch boundary: last epoch-0 header at DAA %d, %s UTC; first epoch-1 header at DAA %d, %s UTC; gap %.1f s (header timestamps)." % (a["daa"], utc(a["t"]), b["daa"], utc(b["t"]), (b["t"] - a["t"]) / 1000.0))
|
||||
# longest gaps
|
||||
gaps = sorted(((body[i]["t"] - body[i - 1]["t"]) / 1000.0, body[i - 1]["daa"], utc(body[i - 1]["t"])) for i in range(1, len(body)))[-5:]
|
||||
print("Longest header-timestamp gaps: " + "; ".join("%.1f s after DAA %d (%s UTC)" % g for g in reversed(gaps)) + ".")
|
||||
# peak minute
|
||||
mins = Counter((r["t"] - t0) // 60_000 for r in body)
|
||||
pk, pn = max(mins.items(), key=lambda x: x[1])
|
||||
print("Peak minute: %d headers in the minute from %s UTC. Headers above 2 blocks/s (per minute): %d." % (pn, utc(t0 + pk * 60_000), sum(n for n in mins.values() if n > 120)))
|
||||
print()
|
||||
# node log
|
||||
try:
|
||||
lines = open(LOG, errors="replace").read().splitlines()
|
||||
except OSError:
|
||||
print("Node log %s not present." % LOG); sys.exit(0)
|
||||
# "PoW accepted <hash> by <engine> (daa N, epoch seed S, ...)": one line per block whose proof of work node 1 verified
|
||||
acc = []
|
||||
pat = re.compile(r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+)([+-]\d\d:\d\d) \[INFO \] PoW accepted ([0-9a-f]+) by (\S+) \(daa (\d+), epoch seed ([0-9a-f]+)")
|
||||
t_end = body[-1]["t"] + 120_000
|
||||
for ln in lines:
|
||||
m = pat.match(ln)
|
||||
if m:
|
||||
t = dt.datetime.fromisoformat(m.group(1) + m.group(2)).timestamp() * 1000
|
||||
acc.append((t, int(m.group(5)), m.group(6), m.group(4)))
|
||||
span = [a for a in acc if t0 <= a[0] <= t_end]
|
||||
print("## Node log %s: %d lines, %d 'PoW accepted' lines in all (to %s UTC), %d inside the record's span to %s UTC" % (LOG, len(lines), len(acc), utc(max(a[0] for a in acc)) if acc else "", len(span), utc(t_end)))
|
||||
print()
|
||||
if span:
|
||||
print("| bucket (UTC) | PoW accepted lines | per s | highest daa in the bucket |")
|
||||
print("|---|---|---|---|")
|
||||
cnt = Counter(int((a[0] - t0) // B) for a in span)
|
||||
hi = defaultdict(int)
|
||||
for a in span:
|
||||
k = int((a[0] - t0) // B); hi[k] = max(hi[k], a[1])
|
||||
for k in range(0, max(cnt) + 1):
|
||||
print("| %s to %s | %d | %.2f | %d |" % (utc(t0 + k * B), utc(t0 + (k + 1) * B), cnt.get(k, 0), cnt.get(k, 0) / 120.0, hi.get(k, 0)))
|
||||
span.sort()
|
||||
g = sorted(((span[i][0] - span[i - 1][0]) / 1000.0, span[i - 1][1], utc(span[i - 1][0])) for i in range(1, len(span)))[-4:]
|
||||
print()
|
||||
print("Longest silences between PoW-accepted lines inside the span: " + "; ".join("%.1f s after daa %d (%s UTC)" % x for x in reversed(g)) + ".")
|
||||
seeds = []
|
||||
for a in span:
|
||||
if not seeds or seeds[-1][1] != a[2]:
|
||||
seeds.append((a[0], a[2], a[1]))
|
||||
print("Epoch seeds in the span: " + "; ".join("%s... from %s UTC (daa %d)" % (s[1][:12], utc(s[0]), s[2]) for s in seeds) + ".")
|
||||
engines = Counter(a[3] for a in span)
|
||||
print("Engines: " + ", ".join("%s %d" % kv for kv in engines.items()) + ".")
|
||||
169
tools/finality-attacks/f20.mjs
Normal file
169
tools/finality-attacks/f20.mjs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Ledger F20 (5 October 2026, night, ledger close round 1): a finality pause under a 45% silent set through at least
|
||||
// three program epoch boundaries, on the fast-time 3-node network (60-DAA epochs, 120-DAA weight window). The silent
|
||||
// set keeps mining and stops signing, so under 2/3 of the window is signing and finality must pause (CLAUDE.md,
|
||||
// FINALITY RULE V2). What is recorded: that every node's template advanced its program epoch at each boundary on
|
||||
// schedule (the template's powEpoch), that every node derived the same epoch seed per index and ended on one sink
|
||||
// (no fork on the seed), that no checkpoint locked during the pause, and that every lock taken before the pause is
|
||||
// still held by every node, with the same hash, after the silent set returns and locking resumes.
|
||||
//
|
||||
// node tools/finality-attacks/f20.mjs # WARM 230 s, PAUSE 200 s, HEAL 150 s, links 100 ms
|
||||
// WARM=230 PAUSE=200 HEAL=150 node tools/finality-attacks/f20.mjs
|
||||
//
|
||||
// Ports 30200 and up, network igneum-devnet-1020, data under /tmp/igneum-fin-f20; the live devnet is never touched.
|
||||
// Topology (as c4.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2, both with DELAY_MS.
|
||||
// Keys: honest h0 h1 on n0, h2 on n1, h3 on n2 at share 0.1375 each (55%); silent s0 on n1, s1 on n2 at share 0.225
|
||||
// each (45%). One block per second in all. The silent keys are restarted with --no-vote for the pause and voting again
|
||||
// for the heal; a vmine key is derived from its label, so the weight stays with the same key across restarts.
|
||||
|
||||
const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/';
|
||||
process.env.IGNEUM_FIN_BASE_PORT ||= '30200';
|
||||
process.env.IGNEUM_FIN_SUFFIX ||= '1020';
|
||||
process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-f20';
|
||||
process.env.IGNEUM_FAST_TIME ||= '1';
|
||||
// the live node line (release-0.3.6, vendor/igneum-node-036) built on the Mac on 5 October 2026
|
||||
process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneumd`;
|
||||
process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneum-miner`;
|
||||
const DELAY_MS = +(process.env.DELAY_MS || 100);
|
||||
const WARM = +(process.env.WARM || 230), PAUSE = +(process.env.PAUSE || 200), HEAL = +(process.env.HEAL || 150);
|
||||
const POLL_MS = +(process.env.POLL_MS || 2000);
|
||||
// a devnet pay address for the template probe (any valid devnet address; the probe never submits)
|
||||
const PAY = process.env.PAY_ADDRESS || 'igneumdev:qpdkezwu04kuscr3hx9wvqhtrnn5xunt4wjjaxsrgmks3z9cjltf58g2zl4c7';
|
||||
|
||||
const { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs');
|
||||
const { mkdirSync, writeFileSync, appendFileSync } = await import('node:fs');
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
const out = (line) => { console.log(line); appendFileSync(`${TMP}/results.md`, line + '\n'); };
|
||||
|
||||
const lockedMap = (cp) => new Map((cp?.checkpoints || []).filter(c => c.state === 'locked').map(c => [c.index, c.hash]));
|
||||
const maxLocked = (cp) => Math.max(0, ...lockedMap(cp).keys());
|
||||
async function checkpoints(node, last = 800) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); }
|
||||
async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo', {}).catch(() => null); return (r?.peerInfo || r?.infos || []).length; }
|
||||
async function dag(node) { return node.rpc.call('getBlockDagInfo', {}).catch(() => null); }
|
||||
async function epoch(node) {
|
||||
const r = await node.rpc.call('getBlockTemplate', { payAddress: PAY, extraData: [] }).catch(() => null);
|
||||
const e = r?.powEpoch; if (!e) return null;
|
||||
return { index: Number(e.epochIndex), seed: String(e.epochSeed), boundary: Number(e.boundaryDaaScore), next: String(e.nextEpochSeed ?? ''), blocks: Number(e.epochBlocks), lead: Number(e.epochLead) };
|
||||
}
|
||||
function keyOf(miner) { const m = miner.logText().match(/key=([0-9a-f]{64})/); return m ? m[1] : null; }
|
||||
|
||||
async function network() {
|
||||
const n1 = new Node(1, { name: 'n1' }); await n1.start();
|
||||
const p0 = new Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }); await p0.start();
|
||||
const p2 = new Proxy(2, n1.p2pPort, { delayMs: DELAY_MS }); await p2.start();
|
||||
const n0 = new Node(0, { name: 'n0', connect: [p0.addr] });
|
||||
const n2 = new Node(2, { name: 'n2', connect: [p2.addr] });
|
||||
await n0.start(); await n2.start();
|
||||
await sleep(3000);
|
||||
log(`network up: peers n0 ${await peers(n0)} n1 ${await peers(n1)} n2 ${await peers(n2)}`);
|
||||
return { n0, n1, n2, p0, p2 };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertBinaries();
|
||||
const t0 = Date.now();
|
||||
const { n0, n1, n2 } = await network();
|
||||
const nodes = [n0, n1, n2];
|
||||
const total = WARM + PAUSE + HEAL + 60;
|
||||
const honestPlan = [[n0, 'h0'], [n0, 'h1'], [n1, 'h2'], [n2, 'h3']];
|
||||
const silentPlan = [[n1, 's0'], [n2, 's1']];
|
||||
const honest = honestPlan.map(([node, label]) => new Miner(node, { label, share: 0.1375, bps: 1, secs: total }).start());
|
||||
let silent = silentPlan.map(([node, label]) => new Miner(node, { label, share: 0.225, bps: 1, secs: WARM + 10 }).start());
|
||||
await sleep(WARM * 1000);
|
||||
// the state at the pause
|
||||
const silentKeys = new Set(silent.map(keyOf).filter(Boolean));
|
||||
const w = await n1.rpc.call('getFinalityWeights', {}).catch(() => ({}));
|
||||
const silentWeight = (w.keys || []).filter(k => silentKeys.has(String(k.keyHash))).reduce((s, k) => s + Number(k.blocks), 0);
|
||||
const before = await Promise.all(nodes.map(n => checkpoints(n)));
|
||||
const beforeMaps = before.map(lockedMap);
|
||||
const beforeMax = beforeMaps.map(m => Math.max(0, ...m.keys()));
|
||||
const preMax = Math.max(...beforeMax);
|
||||
const dagsCut = await Promise.all(nodes.map(dag));
|
||||
const epochsCut = await Promise.all(nodes.map(epoch));
|
||||
log(`pause at warm ${WARM} s: window daa ${w.daaScore}, total weight ${w.totalWeight}, silent keys ${silentKeys.size} hold ${silentWeight} (${w.totalWeight ? (100 * silentWeight / Number(w.totalWeight)).toFixed(1) : '?'}%), voters ${w.voters}, max locked ${beforeMax.join('/')}, daa ${dagsCut.map(d => d?.virtualDaaScore).join('/')}, epoch index ${epochsCut.map(e => e?.index).join('/')}`);
|
||||
// the pause: the silent keys keep mining and stop voting
|
||||
for (const m of silent) await m.stop();
|
||||
const tPause = Date.now();
|
||||
const daaPause = Number(dagsCut[1]?.virtualDaaScore ?? 0);
|
||||
silent = silentPlan.map(([node, label]) => new Miner(node, { label, share: 0.225, bps: 1, secs: PAUSE + 10, vote: false }).start());
|
||||
const transitions = [[], [], []]; // per node: {index, seed, boundary, daaSeen, sAfterPause}
|
||||
const lastIndex = epochsCut.map(e => e?.index ?? -1);
|
||||
const maxDuring = [...beforeMax];
|
||||
let firstNew = [null, null, null];
|
||||
while (Date.now() - tPause < PAUSE * 1000) {
|
||||
const [ds, es, cps] = await Promise.all([Promise.all(nodes.map(dag)), Promise.all(nodes.map(epoch)), Promise.all(nodes.map(n => checkpoints(n)))]);
|
||||
es.forEach((e, i) => {
|
||||
if (e && e.index !== lastIndex[i]) {
|
||||
transitions[i].push({ index: e.index, seed: e.seed, boundary: e.boundary, blocks: e.blocks, daaSeen: Number(ds[i]?.virtualDaaScore ?? NaN), sAfterPause: ((Date.now() - tPause) / 1000).toFixed(1) });
|
||||
lastIndex[i] = e.index;
|
||||
}
|
||||
});
|
||||
cps.forEach((cp, i) => { const m = maxLocked(cp); if (m > maxDuring[i]) maxDuring[i] = m; if (firstNew[i] == null && m > preMax) firstNew[i] = Math.round((Date.now() - tPause) / 1000); });
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
const dagsEndPause = await Promise.all(nodes.map(dag));
|
||||
const daaEndPause = Number(dagsEndPause[1]?.virtualDaaScore ?? 0);
|
||||
const cpsEndPause = await Promise.all(nodes.map(n => checkpoints(n)));
|
||||
const unlockedDuring = cpsEndPause.map(cp => (cp?.checkpoints || []).filter(c => c.daaScore > daaPause && c.state !== 'locked').length);
|
||||
const newLocksDuring = maxDuring.map(m => Math.max(0, m - preMax));
|
||||
log(`end of pause: daa ${daaPause} to ${daaEndPause}, new locks ${newLocksDuring.join('/')}, boundaries seen ${transitions.map(t => t.length).join('/')}`);
|
||||
// the heal: the silent keys vote again
|
||||
for (const m of silent) await m.stop();
|
||||
const tHeal = Date.now();
|
||||
silent = silentPlan.map(([node, label]) => new Miner(node, { label, share: 0.225, bps: 1, secs: HEAL + 10 }).start());
|
||||
let resumedAt = [null, null, null];
|
||||
while (Date.now() - tHeal < HEAL * 1000) {
|
||||
const cps = await Promise.all(nodes.map(n => checkpoints(n)));
|
||||
cps.forEach((cp, i) => { if (resumedAt[i] == null && maxLocked(cp) > maxDuring[i]) resumedAt[i] = Math.round((Date.now() - tHeal) / 1000); });
|
||||
const es = await Promise.all(nodes.map(epoch));
|
||||
es.forEach((e, i) => { if (e && e.index !== lastIndex[i]) { transitions[i].push({ index: e.index, seed: e.seed, boundary: e.boundary, daaSeen: NaN, sAfterPause: 'heal' }); lastIndex[i] = e.index; } });
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
for (const m of [...honest, ...silent]) await m.stop();
|
||||
await sleep(3000);
|
||||
const after = await Promise.all(nodes.map(n => checkpoints(n)));
|
||||
const afterMaps = after.map(lockedMap);
|
||||
const afterMax = afterMaps.map(m => Math.max(0, ...m.keys()));
|
||||
const dagsAfter = await Promise.all(nodes.map(dag));
|
||||
const sinks = dagsAfter.map(d => d?.sink);
|
||||
const converged = new Set(sinks).size === 1;
|
||||
// pre-pause locks: every (index, hash) each node held before the pause is held unchanged by every node after
|
||||
let survived = 0, lost = 0, changed = 0;
|
||||
const union = new Map();
|
||||
for (const m of beforeMaps) for (const [i, h] of m) union.set(i, h);
|
||||
for (const [i, h] of union) for (const m of afterMaps) { if (!m.has(i)) lost++; else if (m.get(i) !== h) changed++; else survived++; }
|
||||
// seeds per epoch index across nodes
|
||||
const seeds = new Map();
|
||||
transitions.forEach((ts, i) => ts.forEach(t => { if (!seeds.has(t.index)) seeds.set(t.index, new Map()); seeds.get(t.index).set(i, t.seed); }));
|
||||
const seedRows = [...seeds.entries()].sort((a, b) => a[0] - b[0]).map(([idx, m]) => ({ idx, agree: new Set(m.values()).size === 1, nodes: m.size, seed: [...m.values()][0] }));
|
||||
const seedFork = seedRows.filter(r => !r.agree).length;
|
||||
const cacheBuilt = nodes.map(n => n.grepLog(/PoW cache built/).length);
|
||||
const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
const boundariesInPause = transitions.map(ts => ts.filter(t => t.sAfterPause !== 'heal').length);
|
||||
// on schedule: the new index was first seen at or after its boundary (index x epoch blocks) and within one poll of it
|
||||
const onSchedule = transitions.map(ts => ts.filter(t => t.sAfterPause !== 'heal').every(t => t.daaSeen >= t.index * t.blocks && t.daaSeen - t.index * t.blocks <= 2 * POLL_MS / 1000 + 3));
|
||||
await stopAll();
|
||||
const pass = boundariesInPause.every(b => b >= 3) && seedFork === 0 && converged && newLocksDuring.every(x => x === 0) && lost === 0 && changed === 0 && resumedAt.every(r => r != null) && conflicts.every(c => c === 0);
|
||||
out(`\n### f20-silent45: warm ${WARM} s, pause ${PAUSE} s with the 45% set mining and not voting, heal window ${HEAL} s, 1 block/s in all, link delay ${DELAY_MS} ms, fast time (epoch 60 DAA, window 120 DAA), node ${IGNEUMD.split('/').slice(-3).join('/')}\n`);
|
||||
out(`Silent set at the pause: ${silentKeys.size} keys holding ${silentWeight} of ${w.totalWeight} window blocks (${w.totalWeight ? (100 * silentWeight / Number(w.totalWeight)).toFixed(1) : '?'}%); window DAA ${w.daaScore}; the pause ran from DAA ${daaPause} to ${daaEndPause}.\n`);
|
||||
out('| measure | n0 | n1 | n2 |');
|
||||
out('|---|---|---|---|');
|
||||
out(`| max locked index at the pause | ${beforeMax.join(' | ')} |`);
|
||||
out(`| epoch index at the pause | ${epochsCut.map(e => e?.index).join(' | ')} |`);
|
||||
out(`| epoch boundaries crossed during the pause | ${boundariesInPause.join(' | ')} |`);
|
||||
out(`| boundary seen within the poll of its DAA (on schedule) | ${onSchedule.join(' | ')} |`);
|
||||
out(`| PoW cache builds in the node log (one per dataset day, M30) | ${cacheBuilt.join(' | ')} |`);
|
||||
out(`| new locks during the pause (index above ${preMax}) | ${newLocksDuring.join(' | ')} |`);
|
||||
out(`| checkpoints proposed in the pause and not locked | ${unlockedDuring.join(' | ')} |`);
|
||||
out(`| first new lock after the heal, s | ${resumedAt.map(x => x ?? 'none').join(' | ')} |`);
|
||||
out(`| max locked index at the end | ${afterMax.join(' | ')} |`);
|
||||
out(`| sink at the end | ${sinks.map(s => String(s).slice(0, 10)).join(' | ')} |`);
|
||||
out(`| conflicting certificates logged | ${conflicts.join(' | ')} |`);
|
||||
out('\nEpoch boundaries during the pause (the template\'s powEpoch, polled every ' + POLL_MS + ' ms; DAA seen = the node\'s virtual DAA at the poll that first showed the new index):\n');
|
||||
out('| node | epoch index | boundary DAA | DAA seen | s after the pause start | seed |');
|
||||
out('|---|---|---|---|---|---|');
|
||||
transitions.forEach((ts, i) => ts.forEach(t => out(`| n${i} | ${t.index} | ${t.boundary} | ${t.daaSeen} | ${t.sAfterPause} | ${t.seed.slice(0, 12)} |`)));
|
||||
out(`\nSeeds agree across the nodes that saw the index: ${seedRows.map(r => `${r.idx}:${r.agree ? 'yes' : 'NO'}(${r.nodes})`).join(' ')}; seed forks ${seedFork}. Pre-pause locks (${union.size} indices across the nodes) after the heal: ${survived} held with the same hash, ${lost} missing, ${changed} with another hash. Sinks ${converged ? 'agree' : 'DISAGREE'}. ${pass ? 'PASS' : 'FAIL'} (3 boundaries on every node, one seed per index, 0 locks in the pause, every pre-pause lock held, locking resumed, 0 conflicts). Wall ${Math.round((Date.now() - t0) / 1000)} s.`);
|
||||
writeFileSync(`${TMP}/results.json`, JSON.stringify({ pass, silentWeight, totalWeight: w.totalWeight, daaPause, daaEndPause, beforeMax, afterMax, newLocksDuring, unlockedDuring, resumedAt, transitions, seedRows, survived, lost, changed, converged, conflicts, cacheBuilt }, null, 2));
|
||||
process.exit(pass ? 0 : 1);
|
||||
}
|
||||
main().catch(async (e) => { log(`threw: ${e.stack || e}`); await stopAll(); process.exit(1); });
|
||||
128
tools/finality-attacks/m21.mjs
Normal file
128
tools/finality-attacks/m21.mjs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Ledger M21 / O-2.2 (5 October 2026, night, ledger close round 1): block propagation with bodies near the mass
|
||||
// limit on the fast-time 3-node network with proxied 100-ms links, and GHOSTDAG k re-derived from the measured delay
|
||||
// with the fork's own `calculate_ghostdag_k` (consensus/core/src/config/bps.rs, ported below).
|
||||
//
|
||||
// node tools/finality-attacks/m21.mjs # sizes 0 and 490,000 bytes of coinbase padding, 180 s each
|
||||
// SIZES=0,100000,490000 SECS=180 DELAY_MS=100 node tools/finality-attacks/m21.mjs
|
||||
//
|
||||
// Ports 30100 and up, network igneum-devnet-1010, data under /tmp/igneum-fin-m21; the live devnet is never touched.
|
||||
// Topology (as c4.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; each proxy holds every
|
||||
// byte DELAY_MS one way, so n0 to n1 is one hop and n0 to n2 two hops plus n1's relay. The proxy adds delay and no
|
||||
// bandwidth limit (loopback), so serialisation time is not in these numbers; the ledger's arithmetic adds it.
|
||||
//
|
||||
// Bodies: the block producer here is the harness itself (getBlockTemplate with `extraData` of SIZE zero bytes, then
|
||||
// submitBlock; the network runs with skip_proof_of_work, the nonce carries a sequence number so every node's
|
||||
// blockAdded notification can be matched to its submit time). The devnet-v4 line retired UTXO transactions from
|
||||
// bodies and EVM transactions ride in the coinbase-carried lanes, so the one body a producer can grow without a
|
||||
// transaction relay is the coinbase payload; `max_coinbase_payload_len` is raised to 600,000 in this network's
|
||||
// override (the fast-time file carries 16,384) and the body stays under the 500,000 compute-mass limit, which counts
|
||||
// every coinbase byte at 1 mass per byte (`check_block_mass`). n0 produces at RATE0 blocks/s and n2 at RATE2, both
|
||||
// padded, so both directions of the two-hop path are measured.
|
||||
|
||||
const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/';
|
||||
process.env.IGNEUM_FIN_BASE_PORT ||= '30100';
|
||||
process.env.IGNEUM_FIN_SUFFIX ||= '1010';
|
||||
process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-m21';
|
||||
process.env.IGNEUM_FAST_TIME ||= '1';
|
||||
process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneumd`;
|
||||
process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneum-miner`;
|
||||
process.env.IGNEUM_FIN_OVERRIDE_JSON ||= JSON.stringify({ max_coinbase_payload_len: 600000 });
|
||||
const DELAY_MS = +(process.env.DELAY_MS || 100);
|
||||
const SIZES = (process.env.SIZES || '0,490000').split(',').map(Number);
|
||||
const SECS = +(process.env.SECS || 180);
|
||||
const RATE0 = +(process.env.RATE0 || 0.7), RATE2 = +(process.env.RATE2 || 0.3);
|
||||
const PAY = process.env.PAY_ADDRESS || 'igneumdev:qpdkezwu04kuscr3hx9wvqhtrnn5xunt4wjjaxsrgmks3z9cjltf58g2zl4c7';
|
||||
|
||||
const { Node, Proxy, stopAll, sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs');
|
||||
const { mkdirSync, writeFileSync, appendFileSync } = await import('node:fs');
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
const out = (line) => { console.log(line); appendFileSync(`${TMP}/results.md`, line + '\n'); };
|
||||
|
||||
// the fork's calculate_ghostdag_k (bps.rs): the smallest k with P[Poisson(x) > k] < delta, x = 2 D bps
|
||||
function ghostdagK(x, delta) {
|
||||
let k = 0, sigma = 0, fraction = 1; const exp = Math.exp(-x);
|
||||
for (; ;) { sigma += exp * fraction; if (1 - sigma < delta) return k; k += 1; fraction *= x / k; }
|
||||
}
|
||||
const q = (xs, p) => { if (!xs.length) return NaN; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
|
||||
const fmt = (xs) => `${q(xs, 0.5)} / ${q(xs, 0.9)} / ${q(xs, 0.99)} / ${Math.max(...xs)}`;
|
||||
|
||||
async function dag(node) { return node.rpc.call('getBlockDagInfo', {}).catch(() => null); }
|
||||
async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo', {}).catch(() => null); return (r?.peerInfo || r?.infos || []).length; }
|
||||
|
||||
async function main() {
|
||||
assertBinaries();
|
||||
if (ghostdagK(10, 0.01) !== 18) throw new Error(`ghostdagK port wrong: D 5 s gives ${ghostdagK(10, 0.01)}, the fork says 18`);
|
||||
const t0 = Date.now();
|
||||
const n1 = new Node(1, { name: 'n1' }); await n1.start();
|
||||
const p0 = new Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }); await p0.start();
|
||||
const p2 = new Proxy(2, n1.p2pPort, { delayMs: DELAY_MS }); await p2.start();
|
||||
const n0 = new Node(0, { name: 'n0', connect: [p0.addr] });
|
||||
const n2 = new Node(2, { name: 'n2', connect: [p2.addr] });
|
||||
await n0.start(); await n2.start();
|
||||
await sleep(3000);
|
||||
const nodes = [n0, n1, n2];
|
||||
log(`network up: peers n0 ${await peers(n0)} n1 ${await peers(n1)} n2 ${await peers(n2)}; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`);
|
||||
// arrivals: nonce -> [t_n0, t_n1, t_n2]
|
||||
const arrivals = new Map();
|
||||
const submits = new Map(); // nonce -> { t, origin, size }
|
||||
nodes.forEach((n, i) => {
|
||||
n.rpc.onNotification = (method, params) => {
|
||||
if (method !== 'blockAddedNotification') return;
|
||||
const block = params?.BlockAdded?.block || params?.block; const nonce = Number(block?.header?.nonce);
|
||||
if (!Number.isFinite(nonce)) return;
|
||||
if (!arrivals.has(nonce)) arrivals.set(nonce, [null, null, null]);
|
||||
if (arrivals.get(nonce)[i] == null) arrivals.get(nonce)[i] = Date.now();
|
||||
};
|
||||
});
|
||||
for (const n of nodes) await n.rpc.call('subscribe', { BlockAdded: {} });
|
||||
let seq = 1;
|
||||
async function producer(node, origin, rate, size, untilMs, stats) {
|
||||
const pad = new Array(size).fill(0);
|
||||
while (Date.now() < untilMs) {
|
||||
const gap = -Math.log(1 - Math.random()) / rate * 1000;
|
||||
await sleep(Math.min(gap, untilMs - Date.now()));
|
||||
if (Date.now() >= untilMs) break;
|
||||
let tm; try { tm = await node.rpc.call('getBlockTemplate', { payAddress: PAY, extraData: pad }, 30000); } catch (e) { stats.templateErrors++; continue; }
|
||||
const nonce = seq++;
|
||||
tm.block.header.nonce = nonce;
|
||||
submits.set(nonce, { t: Date.now(), origin, size, bytes: Math.round(String(tm.block.transactions[0].payload).length / 2) });
|
||||
try { const r = await node.rpc.call('submitBlock', { block: tm.block, allowNonDaaBlocks: false }, 30000); if (r?.report?.type !== 'success') { stats.rejected++; submits.delete(nonce); } else stats.accepted++; }
|
||||
catch (e) { stats.submitErrors++; submits.delete(nonce); }
|
||||
}
|
||||
}
|
||||
const rows = [];
|
||||
for (const size of SIZES) {
|
||||
const stats = { accepted: 0, rejected: 0, templateErrors: 0, submitErrors: 0 };
|
||||
const dagBefore = await Promise.all(nodes.map(dag));
|
||||
const until = Date.now() + SECS * 1000;
|
||||
const firstNonce = seq;
|
||||
await Promise.all([producer(n0, 0, RATE0, size, until, stats), producer(n2, 2, RATE2, size, until, stats)]);
|
||||
await sleep(4000);
|
||||
const dagAfter = await Promise.all(nodes.map(dag));
|
||||
// samples for this size
|
||||
const hop1 = [], hop2 = [], local = [], relay = [];
|
||||
let bytes = 0, n = 0, missing = 0;
|
||||
for (const [nonce, s] of submits) {
|
||||
if (nonce < firstNonce || s.size !== size) continue;
|
||||
const a = arrivals.get(nonce); n++; bytes += s.bytes;
|
||||
if (!a || a.some(x => x == null)) { missing++; continue; }
|
||||
const far = s.origin === 0 ? 2 : 0;
|
||||
local.push(a[s.origin] - s.t); hop1.push(a[1] - s.t); hop2.push(a[far] - s.t); relay.push(a[far] - a[1]);
|
||||
}
|
||||
const D99 = q(hop2, 0.99) / 1000, Dmax = Math.max(...hop2) / 1000, D90 = q(hop2, 0.9) / 1000, D50 = q(hop2, 0.5) / 1000;
|
||||
const row = { size, bytes: n ? Math.round(bytes / n) : 0, blocks: n, missing, stats, local: fmt(local), hop1: fmt(hop1), hop2: fmt(hop2), relay: fmt(relay),
|
||||
k50: ghostdagK(2 * D50, 0.01), k90: ghostdagK(2 * D90, 0.01), k99: ghostdagK(2 * D99, 0.01), kmax: ghostdagK(2 * Dmax, 0.01),
|
||||
blockCount: dagAfter.map(d => d?.blockCount), sinks: new Set(dagAfter.map(d => d?.sink)).size, added: dagAfter.map((d, i) => Number(d?.blockCount) - Number(dagBefore[i]?.blockCount)) };
|
||||
rows.push(row);
|
||||
log(`size ${size}: ${n} blocks (${missing} not seen on every node), body ${row.bytes} B, 1 hop ${row.hop1} ms, 2 hops ${row.hop2} ms, relay ${row.relay} ms, k(p99) ${row.k99}`);
|
||||
}
|
||||
await stopAll();
|
||||
out(`\n### m21-bodies: ${SIZES.join(', ')} bytes of coinbase padding, ${SECS} s each, n0 at ${RATE0} and n2 at ${RATE2} blocks/s, one-way delay ${DELAY_MS} ms per proxied link (n0 to n2 is two links and n1's relay), fast time, node ${IGNEUMD.split('/').slice(-3).join('/')}\n`);
|
||||
out('| padding (B) | coinbase payload (B) | blocks | seen on all 3 | own node p50 / p90 / p99 / max (ms) | 1 hop p50 / p90 / p99 / max (ms) | 2 hops p50 / p90 / p99 / max (ms) | second hop alone (ms) | k from p50 / p90 / p99 / max (delta 0.01, 1 block/s) | blocks per node at the end | sinks |');
|
||||
out('|---|---|---|---|---|---|---|---|---|---|---|');
|
||||
for (const r of rows) out(`| ${r.size} | ${r.bytes} | ${r.blocks} (${r.stats.accepted} accepted, ${r.stats.rejected} rejected, ${r.stats.templateErrors + r.stats.submitErrors} errors) | ${r.blocks - r.missing} | ${r.local} | ${r.hop1} | ${r.hop2} | ${r.relay} | ${r.k50} / ${r.k90} / ${r.k99} / ${r.kmax} | ${r.blockCount.join(' / ')} | ${r.sinks} |`);
|
||||
out(`\nThe delay bound D feeding k is the two-hop time from submit on the origin node to the blockAdded notification on the far node (both stamped by this process); k = calculate_ghostdag_k(2 D, 0.01), which gives 18 at D = 5 s as the fork's comment says. Wall ${Math.round((Date.now() - t0) / 1000)} s.`);
|
||||
writeFileSync(`${TMP}/results.json`, JSON.stringify(rows, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
main().catch(async (e) => { log(`threw: ${e.stack || e}`); await stopAll(); process.exit(1); });
|
||||
117
tools/finality-attacks/x14-concentration.mjs
Normal file
117
tools/finality-attacks/x14-concentration.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Ledger X14 and E16 (5 October 2026, night, ledger close round 1): concentration in hashing, checkpoint signing,
|
||||
// proving and aggregation over the current 30-day window, read-only from a live devnet node's RPCs, plus one live
|
||||
// block's coinbase (the proving-pool output) and one paid proof record (E16, code half).
|
||||
//
|
||||
// node tools/finality-attacks/x14-concentration.mjs [--wrpc ws://127.0.0.1:28640] [--exec http://127.0.0.1:26790]
|
||||
//
|
||||
// What each RPC exposes (vendor/igneum-node-036, release-0.3.6):
|
||||
// getFinalityWeights blue blocks per vote key over the window (hashing concentration), voters, total
|
||||
// getFinalityCheckpoints per checkpoint: state, signed weight, votes seen, the sortition-eligible aggregators and
|
||||
// `certificateAggregator` (the key that built the certificate held, zero when anonymous):
|
||||
// aggregation concentration. It carries NO signer set or bitmap, so signing concentration
|
||||
// per key is not computable from RPC; only signed weight per certificate is.
|
||||
// igneum_getSegment(n) the chain block's carried proof records (prover key, key hash, paid wei, payout): proving
|
||||
// concentration by paid shards per key hash over the window's chain blocks
|
||||
// getBlock(hash, true) the coinbase outputs: the 20% pool output with its OP_RETURN tag (E16)
|
||||
// Never submits anything.
|
||||
|
||||
import { connectRpc } from './lib/rpc.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
|
||||
const WRPC = opt('--wrpc', 'ws://127.0.0.1:28640');
|
||||
const EXEC = opt('--exec', 'http://127.0.0.1:26790');
|
||||
let id = 0;
|
||||
async function exec(method, params = []) {
|
||||
const r = await fetch(EXEC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), signal: AbortSignal.timeout(30000) });
|
||||
const j = await r.json(); if (j.error) throw new Error(j.error.message); return j.result;
|
||||
}
|
||||
const pct = (x) => (100 * x).toFixed(1) + '%';
|
||||
function topShares(counts) {
|
||||
const vals = [...counts.values()].sort((a, b) => b - a); const tot = vals.reduce((s, v) => s + v, 0);
|
||||
const top = (n) => vals.slice(0, n).reduce((s, v) => s + v, 0) / (tot || 1);
|
||||
return { keys: vals.length, total: tot, top1: top(1), top3: top(3), top10: top(10) };
|
||||
}
|
||||
const row = (name, s, unit) => `| ${name} | ${s.keys} | ${s.total} ${unit} | ${pct(s.top1)} | ${pct(s.top3)} | ${pct(s.top10)} |`;
|
||||
|
||||
const rpc0 = await connectRpc(WRPC);
|
||||
const rpc = { call: async (m, p) => { try { return await rpc0.call(m, p, 60000); } catch (e) { throw new Error(`${m} ${JSON.stringify(p).slice(0, 80)}: ${e.message}`); } }, close: () => rpc0.close() };
|
||||
const t0 = Date.now();
|
||||
const dag = await rpc.call('getBlockDagInfo', {});
|
||||
const w = await rpc.call('getFinalityWeights', {});
|
||||
const window = Number(w.params?.weightWindow ?? 7200);
|
||||
// hashing: blue blocks per key over the window
|
||||
const hashing = new Map((w.keys || []).map(k => [String(k.keyHash), Number(k.blocks)]));
|
||||
const voters = (w.keys || []).filter(k => k.voter).length;
|
||||
// checkpoints inside the window: the last window / 30 indices plus some
|
||||
const cps = await rpc.call('getFinalityCheckpoints', { last: Math.ceil(window / 30) + 40 });
|
||||
const inWin = (cps.checkpoints || []).filter(c => Number(c.daaScore) >= Number(w.daaScore) - window);
|
||||
const locked = inWin.filter(c => c.state === 'locked');
|
||||
const certified = inWin.filter(c => c.state === 'locked' || c.state === 'certified');
|
||||
const ZERO = '0'.repeat(64);
|
||||
const aggregation = new Map();
|
||||
let anonymous = 0;
|
||||
for (const c of certified) { const a = String(c.certificateAggregator); if (a === ZERO) anonymous++; else aggregation.set(a, (aggregation.get(a) || 0) + 1); }
|
||||
const eligible = new Map();
|
||||
for (const c of inWin) for (const a of c.aggregators || []) eligible.set(String(a), (eligible.get(String(a)) || 0) + 1);
|
||||
// signing: only the signed weight per certificate is exposed
|
||||
const signedFrac = locked.map(c => Number(c.signedWeight) / Number(c.totalWeight));
|
||||
const votesSeen = locked.map(c => Number(c.votesSeen));
|
||||
// proving: paid shards per prover key hash over the window's chain blocks
|
||||
const budgets = await exec('igneum_getBudgets');
|
||||
const tip = Number(budgets.chainBlocks) - 1;
|
||||
const tipSeg = await exec('igneum_getSegment', ['latest']);
|
||||
const tipDaa = Number(tipSeg.mergeset?.[0]?.daaScore ?? 0);
|
||||
const proving = new Map(), payoutAddr = new Map();
|
||||
let paidRecords = 0, rejectedRecords = 0, carried = 0, chainBlocksRead = 0, firstDaa = null, example = null;
|
||||
for (let n = tip; n >= 1; n--) {
|
||||
let seg; try { seg = await exec('igneum_getSegment', ['0x' + n.toString(16)]); } catch (e) { break; }
|
||||
const daa = Number(seg.mergeset?.find(m => m.hash === seg.hash)?.daaScore ?? seg.mergeset?.[0]?.daaScore ?? 0);
|
||||
if (daa && daa < Number(w.daaScore) - window) break;
|
||||
chainBlocksRead++; firstDaa = daa;
|
||||
for (const r of seg.proofRecords || []) {
|
||||
carried++;
|
||||
if (r.valid && BigInt(r.paidWei) > 0n) {
|
||||
paidRecords++;
|
||||
proving.set(String(r.keyHash), (proving.get(String(r.keyHash)) || 0) + 1);
|
||||
payoutAddr.set(String(r.payout), (payoutAddr.get(String(r.payout)) || 0) + 1);
|
||||
if (!example) example = { carrierNumber: n, carrierHash: seg.hash, record: r, poolCredit: seg.provingPoolCredit, rewards: seg.rewards };
|
||||
} else if (!r.valid) rejectedRecords++;
|
||||
}
|
||||
}
|
||||
const status = await exec('igneum_getProvingStatus');
|
||||
// E16: the carrying block's coinbase on the UTXO side
|
||||
let coinbase = null;
|
||||
if (example) {
|
||||
const b = await rpc.call('getBlock', { hash: example.carrierHash.replace(/^0x/, ''), includeTransactions: true });
|
||||
const cb = b.block?.transactions?.[0];
|
||||
coinbase = { hash: example.carrierHash, outputs: (cb?.outputs || []).map(o => ({ amount: o.value ?? o.amount, script: String(o.scriptPublicKey?.scriptPublicKey ?? o.scriptPublicKey?.script ?? JSON.stringify(o.scriptPublicKey)).slice(0, 80) })), txCount: b.block?.transactions?.length, payloadBytes: cb ? String(cb.payload).length / 2 : null };
|
||||
}
|
||||
rpc.close();
|
||||
const H = topShares(hashing), A = topShares(aggregation), E = topShares(eligible), P = topShares(proving), PA = topShares(payoutAddr);
|
||||
const sorted = (xs) => [...xs].sort((a, b) => a - b);
|
||||
const q = (xs, p) => xs.length ? sorted(xs)[Math.min(xs.length - 1, Math.floor(p * xs.length))] : NaN;
|
||||
const out = [];
|
||||
out.push(`### x14-concentration: Mac observer node ${WRPC} (exec ${EXEC}), read-only, ${new Date().toISOString()}, DAA ${dag.virtualDaaScore}, window ${window} DAA (weights at checkpoint ${w.checkpointIndex}, DAA ${w.daaScore}), ${Math.round((Date.now() - t0) / 1000)} s\n`);
|
||||
out.push('| quantity | keys | total | top-1 | top-3 | top-10 |');
|
||||
out.push('|---|---|---|---|---|---|');
|
||||
out.push(row(`hashing (blue blocks per vote key, getFinalityWeights; ${voters} of ${H.keys} keys are voters above dust ${w.params?.dust})`, H, 'blocks'));
|
||||
out.push(row(`aggregation (certificates built per named aggregator key over ${certified.length} certified or locked checkpoints in the window; ${anonymous} anonymous)`, A, 'certificates'));
|
||||
out.push(row(`aggregator sortition (keys named eligible per checkpoint, ${inWin.length} checkpoints)`, E, 'eligibilities'));
|
||||
out.push(row(`proving (paid proof records per prover key hash over ${chainBlocksRead} chain blocks from DAA ${firstDaa} to the tip; ${carried} records carried, ${rejectedRecords} rejected)`, P, 'paid shards'));
|
||||
out.push(row('proving by payout address (the same records by the EVM address paid)', PA, 'paid shards'));
|
||||
out.push(`| signing | not exposed: no RPC returns a certificate's signer set or bitmap (RpcCheckpoint carries signedWeight, votesSeen, voters, aggregators, certificateAggregator); over ${locked.length} locked checkpoints signed weight over total is p50 ${pct(q(signedFrac, 0.5))}, min ${pct(Math.min(...signedFrac))}, max ${pct(Math.max(...signedFrac))}; votes seen p50 ${q(votesSeen, 0.5)} of ${w.voters} voters | | | | |`);
|
||||
out.push('');
|
||||
out.push(`Proving status (igneum_getProvingStatus): ${status.paidShards} shards paid in all, ${JSON.stringify(status.pool)} in the pool, verifier ${status.verifier}, pool balance ${(Number(BigInt(status.poolBalanceWei)) / 1e18).toFixed(3)} IGN, paid ${(Number(BigInt(status.paidWei)) / 1e18).toFixed(3)} IGN.`);
|
||||
out.push('');
|
||||
if (example) {
|
||||
const r = example.record;
|
||||
out.push(`E16, one live chain block: chain block ${example.carrierNumber} (${example.carrierHash.slice(0, 16)}...) carries a valid proof record for block ${Number(r.number)} shard ${r.shard} by key hash ${String(r.keyHash).slice(0, 16)}... paid ${(Number(BigInt(r.paidWei)) / 1e18).toFixed(6)} IGN to ${r.payout} (the EVM state, igneum_getSegment.proofRecords); the segment's provingPoolCredit is ${(Number(BigInt(example.poolCredit)) / 1e18).toFixed(3)} IGN and its rewards list ${example.rewards.length} miner addresses. The same block's coinbase on the UTXO side (getBlock, ${coinbase?.txCount} transaction(s), coinbase payload ${coinbase?.payloadBytes} B) has ${coinbase?.outputs.length} outputs:`);
|
||||
out.push('');
|
||||
out.push('| output | amount (sompi) | script (hex, first 80) |');
|
||||
out.push('|---|---|---|');
|
||||
(coinbase?.outputs || []).forEach((o, i) => out.push(`| ${i} | ${o.amount} | ${o.script} |`));
|
||||
out.push('');
|
||||
out.push('The tag `igneum-proving-pool-v0` is hex 69676e65756d2d70726f76696e672d706f6f6c2d7630 behind 6a16 (OP_RETURN, 22 bytes).');
|
||||
} else out.push('E16: no paid proof record found in the window.');
|
||||
console.log(out.join('\n'));
|
||||
import('node:fs').then(({ writeFileSync }) => writeFileSync('/tmp/igneum-x14-results.md', out.join('\n') + '\n'));
|
||||
Loading…
Reference in a new issue