Finality v2: fork reading guide, spec implementation notes, bench entry, observer checkpoints, live page locks

- docs/fork-divergence.md: "Finality v2" table (every file, risk, merge note), decisions
- docs/spec/03-finality.md: section 3.10 implementation notes, clause by clause
- docs/bench-log.md: test-network results (72 of 72 steady locks, median 0.80 s; equivocation
  strip; partition: 0 locks at 39.6% of total with the floor binding, heal in 30 s), follower
- tools/observer: live_checkpoints table, FinalityLock subscription, "checkpoint N locked" events
- site: /api/live adds checkpoints and locked/final flags; /live draws the lock ring and final line

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-03 21:54:24 +00:00
parent 7366c5bc96
commit e24b643e01
6 changed files with 253 additions and 28 deletions

View file

@ -252,3 +252,28 @@ Attack, before and after (ignored test `measure_m15_attack_before_and_after`, re
Tests: kaspa-pow `--features igneum-pow` 8 pass (engine smoke, `one_build_per_seed_pair_under_contention`, `live_days_survive_off_day_builds`, `build_queue_is_bounded`, index and live-day helpers, shared-engine, stub); kaspa-consensus header_processor `cheap_checks_run_before_the_pow_engine` pass; kaspa-p2p-flows `pow_guard` 2 pass; the full kaspa-consensus release suite otherwise unchanged.
M16 Metal note (R3.5, cheap reconfirmation only): the Mac `--inline-dataset` shortcut at the 256 MiB cache, 256 MiB dataset, under the same heavy load, ran honest 91.7 Mhash/s against inline 5.29 Mhash/s (inline about 17x slower); this is noisier and slower than the idle-machine figures already in `proto-metal/MEMHARD.md` (10x slower at a 256 MiB dataset, 4.8x at 1 GiB), because the inline kernel is compute-bound and the machine was loaded. The 64 MiB on-die-SRAM emulation M16 wants (inline kernel with a 64 MiB cache, `cacheLog2Words = 24` in `proto-metal/main.swift`) is the RTX 5090 run reserved for the project lead's PC, as R3.5 states; it is not done here and the Mac number above does not price a die.
Not done: the real-engine daemon RPC run (honest blocks need GPU-mined pow, so the measurement used the equivalent validate path with `skip_proof_of_work`); the 64 MiB-cache inline kernel on the 5090; the chain-derived day seed by DAA score (spec 01 section 1.12, still the timestamp-day devnet rule); the VDF epoch seed and finality.
## 3 October 2026, difficulty controller: devnet record, simulator, Igneum dual-lane rule, 3-node CPU test network (consensus-engineer)
Machine: the same Apple M5 Max, shared with two other build agents (load average 60 to 98 during the Rust builds). Fork worktree `vendor/igneum-node-diff`, branch `difficulty` from `d62708a8`. Everything in `docs/analysis/difficulty-2026-10-03.md`; raw outputs in `sim/difficulty/results.md`.
Record: 3,682 headers of the overnight devnet pulled read-only through the observer node's wRPC JSON (`ws://127.0.0.1:28640`, `getBlocks` from genesis) into `sim/difficulty/devnet-2026-10-03.csv`. Kaspa's sampled DAA held genesis difficulty 134,217,727 through block 600 at 0.6 blocks/s (PC, 116 MH/s estimated from the blocks), then eased 4.8x at the first retarget (DAA 600, 19:56:12 UTC) and on to 15.7x (8,552,118) because the 600-block window spanned the 21-minute Metal-only period and a 13-minute idle gap; 5.44 blocks/s over the next five minutes, 355 blocks in the peak minute, 1,633 blocks above 2x, then 1.5x too hard; the DAG widened to 3,681 blocks for 1,656 chain blocks. Exact replay of the record's bits through rusty-kaspa's integer arithmetic matches 244 of 244 retargets while the devnet was a chain and diverges from DAA 845 (merged blocks a chain-only replay cannot see).
Simulator `sim/difficulty/sim.py` (Python, one chain, exponential solve times, 1 BPS): Kaspa sampled DAA, Monero 720, LWMA 60 and 120, the Igneum rule and the brief's literal trigger, on nine synthetic profiles plus the record. Two design findings: Zawy's average-target LWMA estimator is biased while targets ramp (the fast lane stalled at 15x of a 50x step), so every Igneum lane uses work over time (Kaspa's `estimateNetworkHashesPerSecond` estimator); the brief's trigger (short-window rate off target) chatters once the short window is back on target while the long window is still polluted (polluted case 1,876 s against 70 s), so the trigger compares the two lanes. Tuned on the synthetic set only: short window 120, hold 8, prior 16, long lane from 600 blocks of the epoch, trigger 25%, harden 3% per block, ease 10% per block, solvetime cap 20 T.
Settled seconds (121-block mean within 10% for 100 blocks), Kaspa / Monero / LWMA60 / LWMA120 / Igneum: x50 step 1,542 / 94 / 105 / 231 / 62; /50 step 12,296 / 6,433 / 578 / 1,074 / 657 (worst gap 179 / 187 / 119 / 65 / 35 s); epoch +-30% steps 1,583 / 456 / 157 / 153 / 144; 10x hopping never / 284 (6 of 12 never) / 264 / 326 / 212; polluted window 2,748 (peak 7.9x) / 124 (peak 15x) / 66 / 110 / 70; genesis 10x too hard never / 287 / 155 / 188 / 322; steady std of rate 0.012 / 0.037 / 0.131 / 0.092 / 0.038; the record's 75x step never (peak 7.6x, 2,340 blocks above 2x) / 136 / 75 / 157 / 79. With +-500 ms timestamp jitter the ordering holds (Igneum x50 169 s, /50 1,047 s, epoch 55 s, polluted 71 s).
Implementation: `DifficultyRule { KaspaSampled, IgneumDual }` as a network parameter (IgneumDual on all four networks, `"difficulty_rule": "kaspa-sampled"` in the override file selects Kaspa's), `OverrideParams.genesis_bits` (genesis hash recomputed) for test networks, `SampledDifficultyManager::igneum_difficulty_bits` over a selected-chain walk plus the in-epoch samples of the existing window, pure integer core `igneum_target` (Uint320). `cargo test -p kaspa-consensus --lib difficulty`: 9 pass (hold, steady state, 3% harden, 10% ease, trigger, epoch shrinkage, max target, Kaspa's two level-work tests); `cargo test -p kaspa-consensus-core --lib params`: 4 pass. The miner now takes the genesis from the node (pruning point before the first pruning) so it mines an override-genesis network; before the fix it hashed the compiled devnet genesis as the epoch seed and every block was rejected.
TESTNET_BENCH
Not done: no DAG in the simulator (red blocks' work is ignored by every lane, as by Kaspa's estimator); Monero and LWMA reproduced from memory (approximate); real-time targeting left out; the chain walk (600 reads per header early in an epoch) must be re-measured before the 4 BPS step.
## 3 October 2026, igneum-node devnet v2: sustained-mining finality rule v2 on a four-miner test network, and as a follower of the live devnet (consensus-engineer and cryptographer)
Machine: Apple M5 Max, rustc 1.99.0, fork `vendor/igneum-node` at commits "Finality: BLS12-381 vote keys ..." through "Miner: BLS identity ..." (four commits on top of the rename). Builds in `target-finality` (`CARGO_TARGET_DIR=target-finality cargo build --release --features igneum-pow`): `igneumd` 36 MB (66 MB with line tables for the stall hunt), `igneum-miner` 7.6 MB; Windows cross-builds with Homebrew mingw-w64 in `target-finality/x86_64-pc-windows-gnu/release/`: `igneum-miner.exe` 9.7 MB and `igneumd.exe` 44 MB (rocksdb compiled under mingw without trouble, 8 min 13 s). Unit tests: kaspa-consensus-core 71 pass (4 new: key derivation and proof of possession, vote sign/verify/aggregate, section codec with reveal, sortition threshold), kaspa-notify 131, kaspa-rpc-core 20, 0 failures.
Rule as implemented: `docs/spec/03-finality.md` section 3.10 and `docs/fork-divergence.md` "Finality v2". Crypto: blst min-pubkey BLS12-381, votes over `"igneum-vote-v1/" || chain_id || 0 || index || hash`, sortition VRF = SHA-256 of the sortition signature, aggregate certificates with a bitmap over the canonical voter list. Devnet parameters: checkpoint every 30 blue score, determined at +20, weight window 7,200 DAA s, dust 5 blocks, presence 20 indices, 8 aggregators, ban 7,200 DAA s, quorum 2/3 of active and 17/30 of total.
Test network (`--devnet --devnet-suffix=7`, network id igneum-devnet-7, genesis bits 0x1e400000 through `--override-params-file`, finality params as devnet): three igneumd nodes on gRPC 26650/26660/26670, p2p 26651/26661/26671, wRPC JSON 28650/28660/28670 (nodes 2 and 3 `--addpeer` node 1, node 3 also node 2), peers at protocol version 12; four 4-thread CPU `igneum-miner mine --engine igneum-pow` identities m1, m2 (node 1), m3 (node 2), m4 (node 3); 21:59:48 to 22:53 BST. Block rate 0.25 blocks/s per miner (m1: 736 blocks in 3,000 s, 0.072 MH/s, 0 rejected), 2,193 blocks at 22:35; difficulty 149,037 at DAA 2,193.
Key reveal: all four keys revealed from the first block of each identity (`Finality: vote key revealed`), hash matches the header on all three nodes. Weights at checkpoint 4 (DAA 119): 34 + 31 + 30 + 24 blocks, total 119, 4 voters, participation 1.0 (all keys younger than the presence window).
Checkpoints and locks, steady state (indices 1 to 72, all four voting until the equivocation at 43, three after): 72 of 72 determined checkpoints locked on all three nodes; lock latency from determination to lock on node 1: median 0.80 s, p90 1.08 s, max 1.55 s (the vote round trip is bounded by the miners' 1-s poll); first lock 61 s after the first block (checkpoint 1 at blue score 31). Certificates built by the first node to see quorum: node 1 built 91, node 2 92, node 3 90 of 93, 0 conflicting certificates on any node; identical checkpoint hashes, states, signed and total weights on all three nodes at every RPC sample (getFinalityCheckpoints on 28650, 28660, 28670). Checkpoint 2 and 3 locked with 3 of 4 votes (74.6% and 73.0% of total) because the per-template vote carriage and the 1-s poll leave one vote outside the aggregator's first certificate; the lock still met both tests.
Sortition: with 4 voters every voter is eligible (threshold = 1 when voters <= 8); `aggregators` lists the keys whose proof verified, 3 to 4 per checkpoint, and the certificate names the local eligible voter of the building node.
Equivocation (m4 restarted with `--equivocate` at 22:19:41): at index 43 m4 submitted a vote for the checkpoint and one for the hash with its last bit flipped; node 3 answered the second with `accepted=false equivocation=true`, every node logged `EQUIVOCATION by key 56da130c... at index 43 ... weight stripped until daa 8512`, and from checkpoint 44 the key is `voter false, stripped_until 8931` (the ban is re-stamped at each detection, m4 kept equivocating at every index), the voter list is 3, total weight excludes its 526 blocks, and locks continued at 3 of 3 votes (checkpoint 64: 938 of 953 active, 1,400 total). Evidence items were carried in blocks (`evidence` carriers) and re-detected by the follower path; 21 detections on node 3 in 10 minutes.
Partition test (m4 stripped throughout, so the honest set is m1, m2, m3 with 33% of weight each): phase A, m3 stopped 22:31:07 to 22:35:10 (240 s): locks continued (72 at the end, signed 1,088 of 1,105 active and 1,563 total). Phase B, m2 also stopped 22:35:19 to 22:47:48 (749 s), m1 alone voting: 0 locks in 12 checkpoints (73 to 84); at the end m1's weight was 696 of 1,759 total (39.6%) and 696 of 962 active (72.3%), so the ACTIVE test passed as the two silent keys decayed out of the presence window and the 56.7% FLOOR alone held the lock back, which is the sim's 3.3.1 scenario on a real DAG. `finality_active` stayed true until the lock at 72 fell out of the 20-index window. Phase C, m2 and m3 restarted at 22:47:56: the returning miners signed every open index of the presence window, checkpoints 73 to 85 locked within 30 s (determined-to-locked 749 s for 73 down to 121 s for 84, 0 s for 85), 86 to 92 locked at the steady cadence (signed 1,784 of 1,784 at 86, 1,287 of 1,921 at 92, 3 voters). No conflicting certificate and no stall on any node through the heal.
Observer and site: `tools/observer/observer.mjs` with `LIVE_TABLE_PREFIX=fintest_` against 28650 wrote `fintest_live_checkpoints` (49 rows, 42 locked at the first sample) and `checkpoint_locked` events ("checkpoint 42 locked (76.2% of weight, 96.5% of active, 3 votes of 4 voters) at block 0d1405d0"), from both the `FinalityLock` subscription and the 2-s poll; `live_state.finality` carried the weights snapshot. `site/api/live.mjs` adds the `live_checkpoints` query and `locked`/`final` flags per block; `site/live.html` draws the locked-checkpoint ring, the dashed "final" line at the newest lock and the locked counter. Not deployed to Vercel tonight.
Live devnet follower (`igneumd` v2 on gRPC 26690, p2p 26691, JSON 28690, `--connect=127.0.0.1:26611`, never mining): IBD of 5,254 blocks from node 1 in under a second, kept in sync (5,418 blocks at 21:53, 0.70 blocks/s on the live chain), determined checkpoints 1 to 302 within 1 s of IBD; weights at checkpoint 296 (DAA 8,935): 16 keys, 14 above dust, total 7,146 blocks (eight RTX 5090 identities at 862 to 936 blocks, six at 4 to 10), 0 revealed keys, 0 votes, participation 0, 0 locks, `finality_active` false, exactly as expected while the Windows miners run the pre-v2 binary. RSS 1.1 GB. It only ever receives from node 1 (its protocol version 12 against node 1's 11 means no finality messages in either direction).
Open: one stall of all three test nodes at 21:48:43 BST in the first run (stripped binary, right after the follower, the equivocating miner and the test observer started): all three logs stop in the same second, every RPC times out, CPU 0%, node 1 of the live devnet unaffected; not reproduced in 28 minutes of the same scenario on the symbolized build (lock 40 to 93 without a pause, including the equivocation and the partition). The first run's self-deadlock (`compute_weights` taking the state lock its callers hold) was found and fixed before that stall, and `Router::enqueue` is a non-blocking try_send, so the gossip pump cannot deadlock across nodes; cause unknown. Also open: C3 validity rule and F3 pruning bound not enforced; d = 20 chosen without the reorg-depth distribution; the weight walk is O(window) per checkpoint; one certificate per index per node means a certificate often names fewer signers than the votes that exist.
Not demonstrated: locks on the live devnet (its miners do not vote yet), the Windows binaries on Windows, a certificate carried into a block and verified by a cold node that missed the gossip (the follower had no votes to receive), the 2-hour presence window at full length (the run was 53 minutes).

View file

@ -102,6 +102,27 @@ Kept on purpose, not visible outside the tree, so upstream merges stay clean:
Devnet compatibility: the devnet genesis, consensus rules, ports, network id `igneum-devnet` and address prefix `igneumdev` are unchanged, so an `igneumd` built from this commit syncs the chain mined by the earlier `kaspad`-named build and the Windows miner keeps submitting to it (it derives `igneumdev` addresses; a change of the devnet prefix would have broken its templates at cut-over). Verified 3 Oct 2026 20:33 to 20:36 BST: a fourth node from `target-rename/release/igneumd` (built with `CARGO_TARGET_DIR=target-rename cargo build --release -p kaspad -p igneum-miner --features igneum-pow`, 2 min 31 s) on gRPC 26630, P2P 26631, appdir `/tmp/igneum-rename-test`, peered to node 1 at 127.0.0.1:26611, logged `igneumd/2.1.0-745d41ef` as its first line, completed IBD in under a second (140 headers, 140 blocks, sink `7f4cba28...3aa11`, the same sink and DAA score node 1 reported) and node 1 kept running. A fifth node peered to the fourth showed in `getConnectedPeerInfo` as `/igneumd:2.1.0/igneumd:2.1.0/` while node 1 showed as `/kaspad:2.1.0/kaspad:2.1.0/` (the doubled agent is upstream behaviour: `Version::default` and `add_user_agent` both write it). Note for test nodes: `--connect` sets the inbound limit to 0 and skips the P2P listener; use `--addpeer` with `--listen` when another node must dial in. Cut-over for node 1: stop the old `kaspad` process, start `igneumd --devnet` with the same `--appdir`, `--rpclisten` and `--listen`; the database format did not change.
## Execution layer (devnet v3, branch `execution-layer`, 3 Oct 2026)
Implements `docs/design/execution-layer.md` D1 to D10 and section 8.2 on a 3-node simnet with CPU stub miners. Worktree `vendor/igneum-node-exec` on branch `execution-layer`, forked from `d62708a8` (the rename commit). Not merged into `master` yet; the merge plan with the finality branch is in the design document's implementation notes. Status words follow the design document: Implemented means it runs on the test network, prototype means a placeholder the design leaves to measurement.
| File (vendor/igneum-node-exec/) | What changed | Why | Risk | Upstream-merge note |
|---|---|---|---|---|
| `consensus/core/src/evm.rs` (new), `consensus/core/src/lib.rs`, `consensus/core/Cargo.toml` (sha3) | `EvmTransaction = Vec<u8>` (raw EIP-2718 bytes), `evm_tx_hash` (keccak256), `evm_chain_id` (4461 / 4462 / 4463, simnet shares the devnet id), `miner_evm_address` (low 20 bytes of `vote_key_hash`), `BLOCK_EXECUTION_GAS_LIMIT` 30 M, `MAX_EVM_BODY_BYTES` 1 MiB, the `EvmTemplateSource` trait | Design D1, D8, 8.1; the devnet rule for the `miner` address until the body carries a 20-byte field | Low | Pure addition. |
| `consensus/core/src/block.rs` | `Block` and `MutableBlock` gain `evm_transactions` (`Arc<Vec<EvmTransaction>>` / `Vec<EvmTransaction>`); `Block::new` keeps its signature (empty EVM list), `with_evm_transactions`, `from_arcs_with_evm`; `MutableBlock::set_evm_transactions` recomputes the merkle root and re-finalizes the header | Design D1: EVM transactions in the body | Medium by spread: every `Block {..}` literal in the tree had to name the field (six sites) | Upstream additions of `Block` literals will fail to compile until the field is added; the compiler finds them. |
| `consensus/core/src/merkle.rs` | `calc_block_hash_merkle_root(utxo_txs, evm_txs)`: leaves are Kaspa's transaction hashes followed by keccak256 of each raw EVM transaction | Design D7: `hash_merkle_root` is reused as the body commitment and now covers the EVM transactions. With no EVM transactions the root equals Kaspa's, so no genesis hash moved | Low | Pure addition; `calc_hash_merkle_root` is untouched. |
| `consensus/src/model/stores/block_transactions.rs` | Stored body is `BlockBody(utxo_txs, evm_txs)`; `get_evm`, `insert_batch` and `insert` take both | One store, one write per body | Low | Database format changed: a node from before this branch must resync. `insert` has one more argument; upstream call sites conflict trivially. |
| `consensus/src/pipeline/body_processor/processor.rs`, `body_validation_in_isolation.rs`, `consensus/core/src/errors/block.rs`, `consensus/Cargo.toml` | Body validation checks the new merkle root, rejects any UTXO transaction besides the coinbase (`RuleError::UtxoTransactionsRetired`), and runs the state-free EVM body rules through `igneum_evm_types::validate_body` (`RuleError::BadEvmBody`: decoding, signature, chain id, types 0 to 2 only, intrinsic gas within the limit, no duplicate hash, per sender nonces sorted and contiguous, sum of gas limits within `B_e`, body bytes within the limit); `commit_body` writes the EVM part | Design D1 (UTXO transaction type retired from bodies), D4, D5 state-free class | High: changes what every node accepts as a body | Keep. The coinbase stays a UTXO transaction until the UTXO layer is stripped (see "what is missing"). |
| `consensus/src/consensus/mod.rs`, `consensus/core/src/api/mod.rs`, `components/consensusmanager/src/session.rs` | `get_block` and `get_block_even_if_header_only` fill the field; new `get_block_evm_transactions(hash)` on `ConsensusApi` and `async_get_block_evm_transactions` on the session | Readers for the executor and the p2p body server | Low | Mechanical. |
| `protocol/p2p/proto/p2p.proto`, `protocol/p2p/src/convert/block.rs`, `protocol/flows/src/ibd/flow.rs`, `protocol/flows/src/v10/request_block_bodies.rs` | `BlockMessage.evmTransactions = 3` and `BlockBodyMessage.evmTransactions = 2` (`repeated bytes`); `BlockBody` on the wire is `(Vec<Transaction>, Vec<EvmTransaction>)`; IBD body sync and the body server carry both | p2p round trip of the body | Low | Field numbers 3 and 2 must stay unique if upstream adds fields to these messages. Protocol version still 11. |
| `rpc/grpc/core/proto/rpc.proto`, `rpc/core/src/model/block.rs`, `rpc/core/src/model/optional/block.rs`, `rpc/core/src/convert/block.rs`, `rpc/grpc/core/src/convert/block.rs`, `rpc/service/src/converter/consensus.rs`, `rpc/core/src/model/tests.rs` | `RpcBlock`, `RpcRawBlock` and `RpcOptionalBlock` carry `evm_transactions` (gRPC `repeated string evmTransactions = 4` as hex; JSON hex list; Borsh serializer version 2 with a fallback for version 1) | Template to miner and block back carry the EVM part | Low | wRPC Borsh shape changed again (version field bumped, old shape still decodes). |
| `rpc/service/src/service.rs`, `rpc/service/Cargo.toml` | `RpcCoreService` holds an `EvmTemplateSource`; `get_block_template` attaches the execution layer's selection to the mining manager's template with `set_evm_transactions` | Design D1: templates carry EVM transactions. The mining manager and its cache stay UTXO-only; the EVM part is selected per request | Low | A few lines in `get_block_template_call`; conflicts with the finality branch's RPC edits are line-local. |
| `consensus/core/src/config/params.rs` | `SIMNET_PARAMS.blockrate = BlockrateParams::new::<1>().increase_max_block_parents(64)`, `pre_crescendo_target_time_per_block` from `OneBps` | Simnet = the devnet DAG shape (1 BPS, k 18, mergeset 180, merge depth 3,600) without proof of work, so a CPU test network of the execution layer runs the devnet rules | Low | Simnet genesis content and hash unchanged (bits and payload untouched). |
| `kaspad/src/args.rs`, `kaspad/src/daemon.rs`, `kaspad/Cargo.toml` | `--evm-rpclisten=IP[:PORT]` (default port 26790 on devnet and simnet), `--evm-disable`; the daemon builds `igneum_exec::ExecService`, hands its template source to the RPC service and registers it as an async service | Wiring | Low | Mechanical. |
| `igneum/evm-types/` (new crate `igneum-evm-types`) | Decoding through alloy (`TxEnvelope::decode_2718`), sender recovery, Cancun intrinsic gas, the per-transaction and per-body state-free rules; depends on alloy only so consensus and the executor share one decoder | Design 1.5 state-free class in one place | None to consensus beyond its use above | New crate. Pins `alloy-consensus 2.5`, `alloy-primitives 1.7`, `alloy-eips 2.5`. |
| `igneum/exec/` (new crate `igneum-exec`) | The execution layer: `service.rs` chain follower (polls `get_virtual_chain_from_block`, builds segment(C) from `get_block_acceptance_data` which is written in `consensus_ordered_mergeset` order, unwinds reorgs from a 64-deep snapshot ring), `executor.rs` (revm 43 over the segment, rewards by rule, two-dimensional gas, fee flows, skip rule), `pgas.rs` (the prototype pgas table and the per-frame attribution inspector, CREATE rule for the registry), `state.rs` (in-memory revm `CacheDB`, MPT state root through alloy-trie as an output), `pool.rs` (EVM mempool), `rpc.rs` (`eth_*` and `igneum_*` JSON-RPC on axum), `registry.rs` and `contracts/DeveloperRegistry.sol` (system contract at `0x...0210`, runtime bytecode embedded), `bin/diff.rs` (`igneum-exec-diff`, the plain-revm differential harness) | Design sections 1 to 4 and 8.2 | None to consensus (the node runs without it under `--evm-disable`) | New crate. Pins `revm 43`, `alloy-trie 0.9`, `axum 0.8`. The in-memory state and the full-recompute state root are devnet scope (see "what is missing"). |
| `igneum/miner/src/main.rs` | `--vote-key-hash <hex32>` (so a miner's EVM address, the low 20 bytes, is a key it holds), `--hold-ms <n>` (paces the stub miner on a network without proof of work), `--network simnet` (simnet address prefix for label addresses) | Test-network tooling | None | Internal tool. |
## Decisions recorded as open
| Decision | v0 choice | Why it is open |

View file

@ -150,3 +150,30 @@ Designed. The node exposes `finality_active` (true when a certificate has formed
| Two certificates at one index observed (C4 evidence) | Suspend credits until the node's view resolves under 3.5 |
A certified checkpoint overrides the heaviest chain, so fresh hashrate cannot reorganise past `last_certified`; two thirds of 30-day weight can, and 3.1 says what that costs.
## 3.10 Implementation notes (devnet v2, 3 October 2026)
Status of this section: Implemented in `vendor/igneum-node` (reading guide in `docs/fork-divergence.md`, "Finality v2"), tested on a private four-miner test network and as a follower of the live devnet (`docs/bench-log.md`, entry "igneum-node devnet v2"). Where the implementation departs from the rule above or fills a gap it leaves, the departure is listed here, not silently.
| Clause | Implementation | Departure or gap |
|---|---|---|
| W1 | `vote_key_hash` = BLAKE2b (domain `IgneumVoteKeyHash`) of the 48-byte compressed G1 key. The key is revealed with a proof of possession in the miner's coinbase extra data (`IGNK` plus 288 hex characters) and a node registers it only when the hash matches the header. A vote carries the public key too, so a key that votes is revealed by its vote | The reveal is hex, not binary, because the template RPC carries extra data as a UTF-8 string. Mainnet should carry the reveal in a dedicated field or transaction |
| W2 | Blue blocks per key in `(daa(C) - window, daa(C)]`, counted along C's selected chain through every chain block's mergeset blues, C included | O(window) per checkpoint: fine at the devnet window of 7,200 DAA seconds, not at 2,592,000. Mainnet needs an incremental window kept per chain block |
| W3, W5 | Dust excludes a key from the voter list and from both denominators | Key succession (W5) is not implemented |
| C1 | Checkpoint i is the lowest selected-chain block with blue score at least 30 i (blue scores along the chain can skip values), determined when the sink's blue score reaches 30 i + d, d = 20 on devnet. A determination is never revisited | d = 20 is below the placeholder 60; the devnet reorg-depth distribution that sets d has not been recorded |
| C2 | BLS signature over `"igneum-vote-v1/" \|\| chain_id \|\| 0 \|\| index \|\| hash(C_i)` under `IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_`; the chain id is the prefixed network name (`igneum-devnet`, `igneum-devnet-7`); votes are p2p message 70 and ride in the coinbase extra data of every block | |
| C3 | Certificate = index, checkpoint, voter count, signer bitmap over the canonical voter list (keys above dust and not stripped, sorted by key hash), aggregate signature, aggregator key hash and sortition proof. Every template carries the certificates not yet in its past | The validity rule (a block whose selected chain misses a certified checkpoint is invalid) is NOT enforced; only fork choice (F1, F2) is |
| C4 | A second certificate at an index for another block is kept and logged (`conflicting_certificates`) | Not published as evidence, no automatic resolution (3.5 post-heal proposal not implemented) |
| C5 | `min_daa` parameter: 3,600 on mainnet, 0 on devnet | The first-month rule of 3.8 is not implemented |
| Q1, Q2 | Presence window 20 indices on devnet (240 mainnet). Block reading: participation counts the indices in `[i - P, i - 1]` at which a vote by the key is carried by any block, blue or red, in the past of C_i; a key whose first block in the window is younger than P x 30 DAA seconds counts the full window; every template carries up to 48 votes not already in its past, certificates and evidence first | The per-block vote bound (48) is the devnet value of O-3.3 |
| Q3 | Integer tests: `3 x signed x P >= 2 x active_num` (active_num = sum of weight x participation count) and `30 x signed >= 17 x total`, both at C_i; bans known at evaluation time are applied to the voter list | |
| Q4 | No grace: any node aggregates and gossips a certificate the moment the votes it has seen meet Q3 (anyone MAY aggregate); the certificate names a local eligible voter when the node serves one, else a zero aggregator | Aggregator-only publishing and the grace timer (O-3.4) are not implemented; a certificate therefore often carries fewer signers than the votes that exist (the lock still meets Q3) |
| S1 | VRF output = SHA-256 of the voter's BLS signature over `"igneum-sortition-v1/" \|\| chain_id \|\| 0 \|\| index \|\| hash` under the sortition tag (unique per key and message, so the signature is the proof); eligible when `output x voters < 8 x 2^64`, so with 8 or fewer voters everyone is eligible | |
| S2 | Not implemented (sub-user sortition above 8,192 voters) | |
| F1, F2 | In `resolve_virtual` the highest locked checkpoint that is in the future of the depth-based finality point and in the past of some body tip replaces the finality point: tips outside its future are not sink candidates | A lock that no body tip passes through is logged and ignored for that resolution |
| F3 | Not implemented: the pruning point and `virtual_finality_point` ignore locks | Must land before any pruning network |
| F5 | Not implemented (trusted certificate at start) | |
| 3.6 | A second vote by one key at one index for another block is evidence: the key's weight is zero until `detection DAA + ban` (7,200 DAA seconds on devnet), the evidence is carried in blocks and re-detected from blocks | Node-local detection timestamps the ban with the sink's DAA score; a block-carried evidence uses the carrying block's DAA score |
| 3.9 | `getFinalityCheckpoints` reports `finality_active` (a lock within the last P indices) and the latest lock | `last_certified` as a DAA score is not reported |
Node state is one persisted blob (`DatabaseStorePrefixes::IgneumFinality`), written at most once a second; votes received over RPC but not yet carried by a block are lost on restart, votes in blocks are not.

View file

@ -1,6 +1,6 @@
// Igneum live devnet feed. GET /api/live returns what tools/observer wrote to Neon:
// {now, state, blocks (last 90 s), miners (last 10 min), events (last 30)}.
// Three queries, all on indexed timestamps. Cached one second at the edge.
// {now, state, blocks (last 90 s), miners (last 10 min), events (last 30), finality (checkpoints, newest lock)}.
// Four queries, all on indexed columns. Cached one second at the edge.
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
const STALE_AFTER_S = 30;
@ -34,7 +34,7 @@ export default async function handler(req, res) {
}
try {
const sql = neon();
const [head, blockRows, minerRows] = await Promise.all([
const [head, blockRows, minerRows, checkpointRows] = await Promise.all([
sql(`SELECT now() AS now,
(SELECT row_to_json(s) FROM live_state s WHERE s.id = 1) AS state,
(SELECT json_agg(e) FROM (SELECT ts, kind, text FROM live_events ORDER BY ts DESC LIMIT 30) e) AS events`),
@ -48,6 +48,11 @@ export default async function handler(req, res) {
WHERE received_at > now() - interval '10 minutes' AND vote_key_hash IS NOT NULL
GROUP BY vote_key_hash
ORDER BY blocks DESC, last_seen DESC`),
// Finality v2: the most recent checkpoints; an older observer has no table yet, so this query may fail
sql(`SELECT index, hash, blue_score, daa_score, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at, updated_at
FROM live_checkpoints
ORDER BY index DESC
LIMIT 40`).catch(() => []),
]);
const now = new Date(head[0].now).getTime();
@ -77,6 +82,27 @@ export default async function handler(req, res) {
updated_at: s ? s.updated_at : null,
};
const fin = (s && s.finality) || null;
const checkpoints = checkpointRows.reverse().map(c => ({
index: num(c.index), hash: short(c.hash), blue_score: num(c.blue_score), daa: num(c.daa_score), state: c.state,
signed: num(c.signed_weight), active: num(c.active_weight), total: num(c.total_weight),
fraction_active: num(c.fraction_active), fraction_total: num(c.fraction_total), votes: num(c.votes_seen), voters: num(c.voters),
locked_at: c.locked_at, updated_at: c.updated_at,
}));
const lockedHashes = new Set(checkpoints.filter(c => c.state === 'locked').map(c => c.hash));
const newestLock = checkpoints.filter(c => c.state === 'locked').slice(-1)[0] || null;
const finality = {
supported: !!fin,
active: fin ? !!fin.finality_active : false,
next_index: fin ? num(fin.next_index) : null,
latest_locked_index: newestLock ? newestLock.index : (fin ? num(fin.latest_locked_index) || null : null),
latest_locked_hash: newestLock ? newestLock.hash : null,
latest_locked_blue_score: newestLock ? newestLock.blue_score : null,
params: fin ? fin.params || null : null,
weights: fin ? fin.weights || null : null,
checkpoints,
};
const blocks = blockRows.reverse().map(b => ({
hash: short(b.hash),
blue_score: num(b.blue_score),
@ -86,6 +112,9 @@ export default async function handler(req, res) {
parents: (b.parent_hashes || []).map(short),
chain: !!b.is_chain_block,
miner: minerId(b.vote_key_hash),
// Finality v2: a locked checkpoint block, and whether the block sits at or before the newest lock
locked: lockedHashes.has(short(b.hash)),
final: !!(newestLock && b.is_chain_block && num(b.blue_score) <= newestLock.blue_score),
}));
const miners = minerRows.map(m => ({
@ -100,7 +129,7 @@ export default async function handler(req, res) {
const events = (head[0].events || []).map(e => ({ ts: e.ts, kind: e.kind, text: e.text }));
return res.status(200).json({ ok: true, now: new Date(now).toISOString(), state, blocks, miners, events });
return res.status(200).json({ ok: true, now: new Date(now).toISOString(), state, blocks, miners, events, finality });
} catch (e) {
res.setHeader('Cache-Control', 'no-store');
return res.status(500).json({ ok: false, error: String(e.message || e) });

View file

@ -15,6 +15,7 @@ Environment, every value optional:
| `IGNEUM_RPC` | `ws://127.0.0.1:28610` | The node's wRPC JSON url. 28610 is the igneum-devnet wRPC JSON port (`consensus/core/src/network.rs`). Start the node with `--rpclisten-json=127.0.0.1:28610` or the port of your choice. |
| `DATABASE_URL` | read from `~/.config/igneum/env` | Neon connection string. Never commit it. |
| `LIVE_RETAIN_HOURS` | `24` | Hours of blocks kept in `live_blocks`. Older rows are deleted once a minute. |
| `LIVE_TABLE_PREFIX` | empty | Prefix for every table name, so a test observer against a test network can write `fintest_live_*` without touching the site. |
A node started by another tool may listen on gRPC only. Then run your own non-mining peer with a JSON listener, on ports that do not clash with the devnet's (gRPC 26610, P2P 26611):
@ -31,6 +32,7 @@ IGNEUM_RPC=ws://127.0.0.1:28640 node tools/observer/observer.mjs
- Decodes the miner address from the coinbase payload script (same bech32 variant as `crypto/addresses`). The fork's `vote_key_hash` header field is stored per block; the first 8 hex characters are the miner's short id on the site.
- `engine` is the miner's tag in the coinbase extra data after the node's version prefix. The node exposes no engine name over RPC, so this is null on devnet v0.
- When the node refuses the hash-rate estimate (it needs a 1,000-block window) the observer reports blue work added per second over the last 10 minutes instead.
- Finality v2 (3 Oct 2026): subscribes to `FinalityLock` (the node's lock event) and polls `getFinalityCheckpoints` every 2 s and `getFinalityWeights` every 10 s. Every checkpoint the node reports is upserted into `live_checkpoints`; a checkpoint turning `locked` writes the event `checkpoint N locked (xx% of weight, yy% of active, v votes of n voters) at block h`. The weights snapshot (total, active, per key) goes into `live_state.finality`. A node from before the finality layer answers the RPC with an error; the observer then logs once and skips finality.
## Tables
@ -40,7 +42,9 @@ Created on start if missing.
|---|---|---|
| `live_blocks` | one per block, kept `LIVE_RETAIN_HOURS` | `hash`, `blue_score`, `daa_score`, `timestamp_ms`, `parents` (count), `parent_hashes`, `is_chain_block`, `vote_key_hash`, `miner_address`, `engine`, `received_at`. Indexes on `received_at`, `timestamp_ms`, `(vote_key_hash, received_at)`. |
| `live_state` | one row, updated every 2 s | `block_count`, `header_count`, `blue_score`, `difficulty`, `hashes_per_second_estimate`, `peers`, `mempool`, `node_version`, `network`, `blocks_60s`, `blocks_per_minute` (60 pairs of minute epoch ms and count), `observer_started_at`, `updated_at` |
| `live_events` | one per event, kept 7 days | `ts`, `kind`, `text`. Kinds: `observer`, `miner_seen`, `miner_quiet`, `miner_back`, `peer_joined`, `peer_left`, `difficulty` (step over 5%). Checkpoints will be added when the finality layer lands. |
| `live_events` | one per event, kept 7 days | `ts`, `kind`, `text`. Kinds: `observer`, `miner_seen`, `miner_quiet`, `miner_back`, `peer_joined`, `peer_left`, `difficulty` (step over 5%), `checkpoint_locked`. |
| `live_checkpoints` | one per checkpoint index, kept 7 days | `index`, `hash`, `blue_score`, `daa_score`, `state` (proposed, certified, locked), `signed_weight`, `active_weight`, `total_weight`, `fraction_active`, `fraction_total`, `votes_seen`, `voters`, `aggregators` (key hashes whose sortition proof made them aggregators), `locked_at`, `first_seen_at`, `updated_at`. |
| `live_state.finality` | jsonb, updated every 2 s | `params`, `chain_id`, `next_index`, `finality_active`, `latest_locked_index`, `latest_locked_hash`, `latest_locked_blue_score`, `weights` (`total_weight`, `active_weight`, `voters`, `keys[]` with `id`, `blocks`, `voter`, `participation`, `stripped_until_daa`, `revealed`). |
## Reading it

View file

@ -8,8 +8,11 @@
// IGNEUM_RPC wRPC JSON url of the node default ws://127.0.0.1:28610 (igneum-devnet wRPC JSON port)
// DATABASE_URL Neon connection string default: read from ~/.config/igneum/env
// LIVE_RETAIN_HOURS hours of blocks to keep default 24
// LIVE_TABLE_PREFIX prefix for every table name default '' (a test observer can write fintest_live_* instead)
//
// Tables (created on start if missing): live_blocks, live_state, live_events. See README.md.
// Tables (created on start if missing): live_blocks, live_state, live_events, live_checkpoints. See README.md.
// Finality v2: subscribes to FinalityLock notifications and polls getFinalityCheckpoints and getFinalityWeights
// every 2 s; writes the checkpoints table and emits "checkpoint N locked (xx% of weight)" events.
// Zero dependencies: Node 22 WebSocket and fetch, Neon's HTTP SQL endpoint.
import { readFileSync } from 'node:fs';
@ -17,6 +20,8 @@ import { homedir } from 'node:os';
const RPC = process.env.IGNEUM_RPC || 'ws://127.0.0.1:28610';
const RETAIN_HOURS = Number(process.env.LIVE_RETAIN_HOURS || 24);
const T = (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, '');
const TB = `${T}live_blocks`, TS = `${T}live_state`, TE = `${T}live_events`, TC = `${T}live_checkpoints`;
const STATE_EVERY_MS = 2000;
const FLUSH_EVERY_MS = 500;
const PRUNE_EVERY_MS = 60_000;
@ -49,7 +54,7 @@ async function sql(query, params = []) {
async function setupSchema() {
const stmts = [
`CREATE TABLE IF NOT EXISTS live_blocks (
`CREATE TABLE IF NOT EXISTS ${TB} (
hash text PRIMARY KEY,
blue_score bigint NOT NULL,
daa_score bigint NOT NULL,
@ -61,22 +66,41 @@ async function setupSchema() {
miner_address text,
engine text,
received_at timestamptz NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS live_blocks_received_at ON live_blocks (received_at)`,
`CREATE INDEX IF NOT EXISTS live_blocks_timestamp_ms ON live_blocks (timestamp_ms)`,
`CREATE INDEX IF NOT EXISTS live_blocks_vote_key_received ON live_blocks (vote_key_hash, received_at)`,
`CREATE TABLE IF NOT EXISTS live_state (
`CREATE INDEX IF NOT EXISTS ${TB}_received_at ON ${TB} (received_at)`,
`CREATE INDEX IF NOT EXISTS ${TB}_timestamp_ms ON ${TB} (timestamp_ms)`,
`CREATE INDEX IF NOT EXISTS ${TB}_vote_key_received ON ${TB} (vote_key_hash, received_at)`,
`CREATE TABLE IF NOT EXISTS ${TS} (
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
block_count bigint, header_count bigint, blue_score bigint, difficulty double precision,
hashes_per_second_estimate bigint, peers int, mempool int,
node_version text, network text,
blocks_60s int, blocks_per_minute jsonb,
observer_started_at timestamptz, updated_at timestamptz)`,
`CREATE TABLE IF NOT EXISTS live_events (
`ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS finality jsonb`,
`CREATE TABLE IF NOT EXISTS ${TE} (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
kind text NOT NULL,
text text NOT NULL)`,
`CREATE INDEX IF NOT EXISTS live_events_ts ON live_events (ts)`,
`CREATE INDEX IF NOT EXISTS ${TE}_ts ON ${TE} (ts)`,
`CREATE TABLE IF NOT EXISTS ${TC} (
index bigint PRIMARY KEY,
hash text NOT NULL,
blue_score bigint NOT NULL,
daa_score bigint NOT NULL,
state text NOT NULL,
signed_weight bigint NOT NULL DEFAULT 0,
active_weight bigint NOT NULL DEFAULT 0,
total_weight bigint NOT NULL DEFAULT 0,
fraction_active double precision NOT NULL DEFAULT 0,
fraction_total double precision NOT NULL DEFAULT 0,
votes_seen int NOT NULL DEFAULT 0,
voters int NOT NULL DEFAULT 0,
aggregators text[] NOT NULL DEFAULT '{}',
locked_at timestamptz,
first_seen_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS ${TC}_state ON ${TC} (state, index)`,
];
for (const s of stmts) await sql(s);
}
@ -134,7 +158,12 @@ function minerFromCoinbase(block, prefix) {
const extra = b.slice(19 + len);
// The node prefixes the extra data with its own version tag ("2.1.0/"). Anything after that is the
// miner's tag (engine or label). The node exposes no engine name over RPC, so this is null on devnet v0.
const text = Buffer.from(extra).toString('utf8').replace(/[^\x20-\x7e]/g, '').replace(/^\d+\.\d+\.\d+(-[\w.]+)?\//, '').trim();
// Finality v2: the miner's key reveal (IGNK ...) and the node's finality section (... IGNF) are binary; only the
// text before the reveal is the miner's tag.
let text = Buffer.from(extra).toString('latin1');
const reveal = text.indexOf('IGNK');
if (reveal >= 0) text = text.slice(0, reveal);
text = text.replace(/[^\x20-\x7e]/g, '').replace(/^\d+\.\d+\.\d+(-[\w.]+)?\//, '').trim();
return { address: scriptToAddress(prefix, script), extra: text ? text.slice(0, 64) : null };
}
@ -209,7 +238,7 @@ let sinkHash = null;
async function recordEvent(kind, text) {
log('event', kind, text);
try { await sql('INSERT INTO live_events (kind, text) VALUES ($1, $2)', [kind, text]); } catch (e) { log('event write failed', e.message); }
try { await sql(`INSERT INTO ${TE} (kind, text) VALUES ($1, $2)`, [kind, text]); } catch (e) { log('event write failed', e.message); }
}
function noteArrival(now) {
@ -262,14 +291,14 @@ async function flushBlocks() {
values.push(`(${ph.join(',')})`);
}
try {
await sql(`INSERT INTO live_blocks (${cols.join(',')}) VALUES ${values.join(',')} ON CONFLICT (hash) DO NOTHING`, params);
await sql(`INSERT INTO ${TB} (${cols.join(',')}) VALUES ${values.join(',')} ON CONFLICT (hash) DO NOTHING`, params);
} catch (e) { log('block insert failed', e.message); pendingBlocks.unshift(...rows.slice(0, 50)); }
if (pendingBlocks.length) await flushBlocks();
}
async function flushChain() {
if (pendingChain.add.size) { const a = [...pendingChain.add]; pendingChain.add.clear(); try { await sql('UPDATE live_blocks SET is_chain_block = true WHERE hash = ANY($1::text[]) AND NOT is_chain_block', [pgArray(a)]); } catch (e) { log('chain update failed', e.message); } }
if (pendingChain.remove.size) { const r = [...pendingChain.remove]; pendingChain.remove.clear(); try { await sql('UPDATE live_blocks SET is_chain_block = false WHERE hash = ANY($1::text[]) AND is_chain_block', [pgArray(r)]); } catch (e) { log('chain update failed', e.message); } }
if (pendingChain.add.size) { const a = [...pendingChain.add]; pendingChain.add.clear(); try { await sql(`UPDATE ${TB} SET is_chain_block = true WHERE hash = ANY($1::text[]) AND NOT is_chain_block`, [pgArray(a)]); } catch (e) { log('chain update failed', e.message); } }
if (pendingChain.remove.size) { const r = [...pendingChain.remove]; pendingChain.remove.clear(); try { await sql(`UPDATE ${TB} SET is_chain_block = false WHERE hash = ANY($1::text[]) AND is_chain_block`, [pgArray(r)]); } catch (e) { log('chain update failed', e.message); } }
}
async function tick(rpc) {
@ -308,34 +337,114 @@ async function tick(rpc) {
// miners gone quiet
for (const [vk, m] of miners) if (!m.quiet && now - m.lastSeen > QUIET_AFTER_MS) { m.quiet = true; recordEvent('miner_quiet', `Miner ${short(vk)} has gone quiet (no block for 5 min)`); }
// finality v2: checkpoints and weights (a node from before the finality layer answers with an error; then null)
const finality = await finalityTick(rpc);
try {
await sql(`INSERT INTO live_state (id, block_count, header_count, blue_score, difficulty, hashes_per_second_estimate, peers, mempool,
node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at)
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
await sql(`INSERT INTO ${TS} (id, block_count, header_count, blue_score, difficulty, hashes_per_second_estimate, peers, mempool,
node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at, finality)
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now(), $13::jsonb)
ON CONFLICT (id) DO UPDATE SET block_count = EXCLUDED.block_count, header_count = EXCLUDED.header_count, blue_score = EXCLUDED.blue_score,
difficulty = EXCLUDED.difficulty, hashes_per_second_estimate = EXCLUDED.hashes_per_second_estimate, peers = EXCLUDED.peers,
mempool = EXCLUDED.mempool, node_version = EXCLUDED.node_version, network = EXCLUDED.network, blocks_60s = EXCLUDED.blocks_60s,
blocks_per_minute = EXCLUDED.blocks_per_minute, observer_started_at = EXCLUDED.observer_started_at, updated_at = now()`,
blocks_per_minute = EXCLUDED.blocks_per_minute, observer_started_at = EXCLUDED.observer_started_at, updated_at = now(),
finality = EXCLUDED.finality`,
[dag.blockCount, dag.headerCount, (await rpc.call('getSinkBlueScore', {}).catch(() => ({}))).blueScore ?? null, diff,
hps ? hps.networkHashesPerSecond : localHashesPerSecond(), peers.length, info.mempoolSize ?? 0,
nodeVersion, network, blocks60s(now), JSON.stringify(blocksPerMinute(now)), STARTED_AT.toISOString()]);
nodeVersion, network, blocks60s(now), JSON.stringify(blocksPerMinute(now)), STARTED_AT.toISOString(),
finality ? JSON.stringify(finality) : null]);
} catch (e) { log('state write failed', e.message); }
}
// ---------- Finality v2 (spec 03) ----------
const checkpointStates = new Map(); // index -> last state written
let finalitySupported = null; // null = unknown, false = the node has no finality RPC
let lastWeightsAt = 0;
let lastWeights = null;
function pct(x) { return (Number(x) * 100).toFixed(1); }
async function recordLock(cp) {
// The lockEvent text the site shows: "checkpoint N locked (xx% of weight)"
const votes = cp.votesSeen !== undefined ? `${cp.votesSeen} votes of ${cp.voters} voters` : `${cp.voters} voters`;
recordEvent('checkpoint_locked', `checkpoint ${cp.index} locked (${pct(cp.fractionTotal)}% of weight, ${pct(cp.fractionActive)}% of active, ${votes}) at block ${short(cp.hash)}`);
}
async function upsertCheckpoint(cp) {
const locked = cp.state === 'locked';
try {
await sql(`INSERT INTO ${TC} (index, hash, blue_score, daa_score, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total,
votes_seen, voters, aggregators, locked_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::text[], $14, now())
ON CONFLICT (index) DO UPDATE SET hash = EXCLUDED.hash, blue_score = EXCLUDED.blue_score, daa_score = EXCLUDED.daa_score, state = EXCLUDED.state,
signed_weight = EXCLUDED.signed_weight, active_weight = EXCLUDED.active_weight, total_weight = EXCLUDED.total_weight,
fraction_active = EXCLUDED.fraction_active, fraction_total = EXCLUDED.fraction_total, votes_seen = EXCLUDED.votes_seen, voters = EXCLUDED.voters,
aggregators = EXCLUDED.aggregators, locked_at = COALESCE(${TC}.locked_at, EXCLUDED.locked_at), updated_at = now()`,
[cp.index, cp.hash, cp.blueScore, cp.daaScore, cp.state, cp.signedWeight, cp.activeWeight, cp.totalWeight, cp.fractionActive, cp.fractionTotal,
cp.votesSeen, cp.voters, pgArray(cp.aggregators || []), locked ? new Date().toISOString() : null]);
} catch (e) { log('checkpoint write failed', e.message); }
}
async function finalityTick(rpc) {
if (finalitySupported === false) return null;
let cps;
try { cps = await rpc.call('getFinalityCheckpoints', { last: 60 }); }
catch (e) {
if (finalitySupported === null) { log('node has no finality RPC (pre-v2 node):', e.message); finalitySupported = false; }
return null;
}
finalitySupported = true;
for (const cp of cps.checkpoints || []) {
const prev = checkpointStates.get(cp.index);
if (prev !== cp.state || prev === undefined) {
await upsertCheckpoint(cp);
if (cp.state === 'locked' && prev !== 'locked') await recordLock(cp);
checkpointStates.set(cp.index, cp.state);
} else if (cp.state !== 'locked' && (cp.index % 1 === 0)) {
// vote counts move while a checkpoint is open: refresh it
await upsertCheckpoint(cp);
}
}
for (const k of checkpointStates.keys()) if (k + 500 < Number(cps.nextIndex)) checkpointStates.delete(k);
const now = Date.now();
if (now - lastWeightsAt > 10_000) {
lastWeightsAt = now;
try {
const w = await rpc.call('getFinalityWeights', {});
lastWeights = {
checkpoint_index: w.checkpointIndex, checkpoint_hash: w.checkpointHash, daa_score: w.daaScore,
total_weight: w.totalWeight, active_weight: w.activeWeight, voters: w.voters,
keys: (w.keys || []).slice(0, 64).map(k => ({ id: short(k.keyHash), key_hash: k.keyHash, blocks: k.blocks, voter: k.voter, participation: k.participation, stripped_until_daa: k.strippedUntilDaa, revealed: !!k.pubkey })),
};
} catch (e) { if (!String(e.message).includes('no checkpoint')) log('getFinalityWeights failed', e.message); }
}
const locked = (cps.checkpoints || []).filter(c => c.state === 'locked');
const newest = locked.length ? locked[locked.length - 1] : null;
return {
params: cps.params, chain_id: cps.chainId, next_index: cps.nextIndex, finality_active: cps.finalityActive,
latest_locked_index: cps.latestLockedIndex, latest_locked_hash: cps.latestLockedHash,
latest_locked_blue_score: newest ? newest.blueScore : null,
weights: lastWeights,
};
}
async function prune() {
try {
await sql(`DELETE FROM live_blocks WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]);
await sql(`DELETE FROM live_events WHERE ts < now() - interval '7 days'`);
await sql(`DELETE FROM ${TB} WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]);
await sql(`DELETE FROM ${TE} WHERE ts < now() - interval '7 days'`);
await sql(`DELETE FROM ${TC} WHERE updated_at < now() - interval '7 days'`);
} catch (e) { log('prune failed', e.message); }
}
async function seedFromDb() {
try {
const rows = await sql(`SELECT vote_key_hash, max(received_at) AS last FROM live_blocks WHERE vote_key_hash IS NOT NULL AND received_at > now() - interval '1 day' GROUP BY 1`);
const rows = await sql(`SELECT vote_key_hash, max(received_at) AS last FROM ${TB} WHERE vote_key_hash IS NOT NULL AND received_at > now() - interval '1 day' GROUP BY 1`);
for (const r of rows) miners.set(r.vote_key_hash, { lastSeen: new Date(r.last).getTime(), quiet: Date.now() - new Date(r.last).getTime() > QUIET_AFTER_MS });
const mins = await sql(`SELECT (floor(extract(epoch from received_at) / 60) * 60000)::bigint AS m, count(*)::int AS n FROM live_blocks WHERE received_at > now() - interval '61 minutes' GROUP BY 1`);
const mins = await sql(`SELECT (floor(extract(epoch from received_at) / 60) * 60000)::bigint AS m, count(*)::int AS n FROM ${TB} WHERE received_at > now() - interval '61 minutes' GROUP BY 1`);
for (const r of mins) minuteCounts.set(Number(r.m), r.n);
const st = await sql(`SELECT difficulty FROM live_state WHERE id = 1`);
const st = await sql(`SELECT difficulty FROM ${TS} WHERE id = 1`);
const cps = await sql(`SELECT index, state FROM ${TC} WHERE updated_at > now() - interval '1 day'`);
for (const r of cps) checkpointStates.set(Number(r.index), r.state);
if (st.length && st[0].difficulty) lastDifficultyEvent = Number(st[0].difficulty);
} catch (e) { log('seed failed', e.message); }
}
@ -348,6 +457,15 @@ async function main() {
rpc.onNotification = (method, params) => {
const inner = params && (params.BlockAdded || params.VirtualChainChanged || params);
if (method === 'blockAddedNotification' && inner && inner.block) onBlock(inner.block);
else if (method === 'finalityLockNotification' && inner) {
// The node's lockEvent: write the checkpoint and the event at once; the 2 s poll fills in the rest
const cp = inner.FinalityLock || inner;
if (cp.index !== undefined && checkpointStates.get(Number(cp.index)) !== 'locked') {
checkpointStates.set(Number(cp.index), 'locked');
upsertCheckpoint({ ...cp, state: 'locked', aggregators: [] });
recordLock(cp);
}
}
else if (method === 'virtualChainChangedNotification' && inner) {
for (const h of inner.addedChainBlockHashes || []) { pendingChain.remove.delete(h); pendingChain.add.add(h); }
for (const h of inner.removedChainBlockHashes || []) { pendingChain.add.delete(h); pendingChain.remove.add(h); }
@ -364,6 +482,7 @@ async function main() {
addressPrefix = network.includes('devnet') ? 'igneumdev' : network.includes('testnet') ? 'igneumtest' : network.includes('simnet') ? 'igneumsim' : 'igneum';
await rpc.call('subscribe', { BlockAdded: {} });
await rpc.call('subscribe', { VirtualChainChanged: { include_accepted_transaction_ids: false } });
await rpc.call('subscribe', { FinalityLock: {} }).catch(e => log('FinalityLock subscription refused (pre-v2 node?):', e.message));
log(`subscribed on ${network}, node ${nodeVersion}`);
if (connected) recordEvent('observer', 'Observer reconnected to the node'); else recordEvent('observer', `Observer started against ${network}`);
connected = true;