Merge remote-tracking branch 'origin/pool-finish-21' into release-0.3.21

This commit is contained in:
igneum-labs 2026-10-07 14:05:59 +00:00
commit 0fb1cf9a09
28 changed files with 3127 additions and 141 deletions

View file

@ -296,6 +296,8 @@ Evidence: design doc Finality v2, Residual risks bullet 3. Fix: overclaims list,
Sweep (5 October 2026, evening): stated. `site/litepaper.html`, Finality ends with the pool sentence (overclaim 33); Governance opens with "governed by the hashrate that powers it", pool concentration named as the governance risk with the devnet measurement, top-3 keys 34.5% of 8,090 blocks (ledger sweep, X14 run). Overclaim 43.
Narrowed (7 October 2026, the pool lane, mission item 11): a conforming pool holds no vote. Pool-0 (`pool/`, spec 09) names the member's own vote key in every header it issues and holds no key of its own, so its hashers' weight is theirs (measured on the private fast-time runs of 5 and 7 October: every pool block carried the finding member's key); and the open pool (spec 09 section 9.12, `igneum-pool --open`) has no operator at all: every member builds its own templates from its own node, and a block pays the window from its own coinbase by the executor's split rule. The litepaper's sentence stays true of a custodial pool (`vote_mode: pool`, refused by the member's client by default and visible on chain as one key per payout address); the next litepaper pass should say "a custodial pool" where it says "pools".
### F11. VDFs are exotic
"A class-group VDF with Wesolowski proofs in a consensus-critical path, in a project with no cryptographer. Chia needed years and still got timelord ASICs."
@ -655,6 +657,8 @@ Answer: Correct. Stratum v2 job declaration lets a hasher choose transactions wh
Evidence: none in repository. Fix: overclaims list, item 45.
Narrowed (7 October 2026, the pool lane): on Igneum the vote key in a pool's header is the member's, not the pool's (spec 09 section 9.6, built in `pool/` since 5 October and in the open pool since 7 October), so the vote-key caveat no longer applies to a conforming pool. Transaction choice: still the pool's in mode A (mode C, the member's own template, is designed and not built); in the open pool the member's own node chooses the transactions, since the member builds every template, which is Stratum v2's job declaration without an operator to decline it. The litepaper's "pools can be bypassed on transaction choice" stays, with "and in the open pool there is no pool to bypass" when it is next edited.
Cross-reference (external review, 3 October 2026, night): whether members use declared templates is measured under O-9.5; concentration reporting is X14.
Sweep (5 October 2026, evening): stated. `site/litepaper.html`, Governance bullet "Pools can be bypassed on transaction choice", a pool may decline, vote keys stay with the pool, label Designed with spec section 9 (overclaim 44).

View file

@ -236,3 +236,262 @@ a wrong-size cache and mismatched silently); a pool on a class v4 network needs
Building the pool crate on igneum-build-1: the pool reads the fork through the `vendor/igneum-node` symlink; lib.sh syncs
the fork worktree it points at (`vendor/igneum-node-pr`) as a whole repository, and the symlink itself is created once on
the box by hand (`ln -s igneum-node-pr /srv/builds/<worktree>/vendor/igneum-node`), the one step the scripts do not do.
### 9.1 7 October 2026: the daemon refuses to start half-alive
The fleet agent's night (6 October, 23:01Z to 23:32Z): the rebased pair never ran as a test. The daemon was started while
the node still held its member port, then four members (the wind-down's weight rule freed four pods, not ten) sat
connected with no job while every template fetch timed out; nothing in the daemon's own log said so except one line per
member. The row from it, now code (pool `269364a`+): the member and API listeners are bound in `main` before anything
else and a port that cannot be bound ends the start with exit code 2 and `POOL NOT STARTED: cannot bind the members
listener on <addr>: <os error>`; the node must answer a template with its `pow_epoch` before the pool serves anyone,
retried for `--node-wait-secs` (default 60, each attempt logged), else exit code 3; a `STATUS` line every 30 s
(`members= jobs_issued= shares_accepted= shares_rejected= blocks= template_failures=; node ok | NODE NOT ANSWERING |
NO TEMPLATE YET`). Test: `server::bind_tests` (a held port refused with the address in the message, the freed port binds).
The 10-member run is still owed: a clean 30-minute window, the daemon bound and its `node ... answers templates` line
printed before the first member, on the next pods the weight rule frees (the table must read over 75 percent signed first).
### 9.2 7 October 2026, 06:00Z to 06:33Z: the re-run (five members), what it settled and what it found
Settled: the class question. pm-1 and pm-2 (RTX 3070, grpc url `none`) at +7 min: 36,917 and 34,323 shares accepted, 0
rejected, 0 WORKER MISMATCH, 0 refused, on the class v3 program of the pool's node's epoch (`5530a50d...`, era = the devnet
genesis hash), the genesis day (20729) and dataset size (2^28) installed from the daemon's `seeds` line. The 6 October
failure does not reproduce.
Found: the daemon stalls on a real chain. From 06:01Z (the first block found) no job was issued for seven minutes, pm-3
and pm-4 never got one, every block found on the stale templates (55, then 105, 480 DAA behind the virtual) was an
orphan, vardiff could not act (a new share target rides with a job) so the two hashing members sent about 90 shares a
second each and the share check read 9.8 ms on pool-1's cores (1.35 ms on the Mac); after a swap to a 20 s template
timeout (f808b3f3) with the data dir kept, the stall returned within a minute. The node built templates in 0.6 ms all
the while (its prewarm line) and served its own solo miner.
The cause, from the daemon's code: `confirm_loop` restarted its chain walk from the PRUNING POINT on any failed
`getVirtualChainFromBlock`, and then called `get_block` for every chain block since (about 120,000 on the devnet) over
the ONE gRPC connection the templates used (the client is one request stream per connection, 5 s request timeout); one
timed-out request under four parallel template fetches started the walk, every later request waited behind it and
timed out, which restarted the walk again. The kept state file (105 pending blocks) restarted it after the swap. The
private measurement run (section 5) never saw it: a 600-block chain walks in a moment.
Fixed (pool commit after bd49c2a9): the walk and the network numbers run on a second gRPC connection (`pool.walker`); a
failed chain call keeps its cursor (moved to the sink only when the node no longer knows it, never to the pruning
point; test `node::walk_tests`); a tick walks at most 600 chain blocks and says how many; a start with pending blocks
walks from the sink and says that older ones resolve by the orphan rule. `--template-timeout-s` stays (default 20).
Still open from the night, for the next window: the node log showed a new gRPC connection about every 0.8 s with the
count steady at 5 (something opens and closes one each time; the daemon's re-subscribe loop is the suspect, the
subscribe-line count in the daemon log decides it); vardiff's new target should apply to the member's current work
without waiting for a job (a one-line member change, needs a miner rebuild); the 9.8 ms share check on the pool box's
cores against 1.35 ms on the Mac (the verifier's day cache is 1 GiB and random reads on a rented box's memory are the
cost; two verify threads saturate at about 200 shares a second, which vardiff must keep far away); and the 10-member
load figure itself, not taken.
Consequences per tier: a pool operator needs a box whose memory serves the 1 GiB cache at speed (a rented 2 vCPU box
verifies about 100 shares a second per core at 9.8 ms; at one share per 10 s per member that is 1,000 members per
core, so the verifier is not the limit once vardiff holds); a member on any card is unaffected by any of this; the
chain walk now costs the node at most 600 `get_block` calls per 5 s on its own connection.
### 9.3 The re-run's close (06:33:33Z) and the member rows from its logs
After 2f6c0358 (06:20:34Z to 06:33:33Z, 13 minutes, four members): 207 blocks found, 144 confirmed, 69 orphaned (13
inherited pending at the swap, 56 on its own jobs: a 27 percent residual at 1 bps), 20,404 shares accepted, 0 rejected,
0 mismatches, 0 refused, every STATUS `node ok`, the gRPC connection churn gone (ids #3 to #5 once each in a minute; the
193 connections in 150 s were under f808b3f3's pruning-point walk), re-subscribe lines 2 (one per daemon start: the
re-subscribe loop was never the churn). Before it, f808b3f3 had worked through the walk by about 06:14Z: 245 blocks,
69 confirmed, 163 orphaned. First CONFIRMED line 06:24:32Z, 4.80 IGN to the finder. Logs: `~/Desktop/fleet/pool-run-0607/`.
The residual orphans are template latency: the daemon's STATUS reads `last template 1 s ago` and the solo miner on the
same node reads `template_ms=1632`; pool-1's node answers a template request in 1 to 1.6 s although its mining manager
answers a per-member request from its cache by a coinbase rewrite (`modify_block_template`) and its prewarm builds in
0.6 ms. At 1 bps a template that arrives 1.6 s old loses about a quarter of the blocks found on it, pool or solo. That
latency is the node's RPC path, a row for the node lane (measure `get_block_template` round trips on a devnet node under
a miner and a pool; the suspect is the RPC service's `pow_epoch` derivation per request). 82 "RPC request timeout" lines
after the 2f6c0358 swap are the gRPC client's own 5 s request timeout on that path; `--template-timeout-s` cannot
lengthen it.
From pm-1's log (1.09 GB): 4,342,515 lines are `worker: error N epoch seed mismatch: this worker holds epoch eea66ce7...`,
one per re-queued job, for the 40 s the worker compiled the new epoch's pack (NVRTC 40,327 ms) and again after every
reconnect, because a session respawned the worker (a second process beside the first, the same compile again) and
the member fed jobs for a pair the worker did not hold yet. Vardiff from the same log: shift 10 to 11 (idle easing
while the worker compiled, which consumed the sized first correction), then 11 down to 0 one step per 30 s over 5.5
minutes at about 190 shares a second, the load that put the share check at 10 to 11 ms on pool-1's cores.
Fixed in the member (fork commit after b8070476): one worker process per run, jobs held for a pair whose prepare is
pending and never re-prepared once held (`WorkerMemory`, test `jobs_are_held_while_a_pair_is_being_prepared`), worker
error lines summarised (one per class, then a count per 1,000), `set_target` applied to the current work at once.
Fixed in the pool (`vardiff.rs`): the idle easing no longer consumes the sized first correction (test
`an_idle_easing_does_not_consume_the_sized_first_correction`).
Consequences per tier: a member on a 3070-class card loses 40 s of hash at every epoch roll to the NVRTC compile unless
its pack is prepared ahead (the pool's `seeds` line carries `next_epoch_seed` for that, the member's prepare-ahead is
the next member row); a member on a reconnect now keeps its worker and its compiled pair; a pool operator's verifier
sees the sized correction within 10 s of a member's first shares (one share per 10 s per member from then on) instead of
5 minutes of a 190-share-a-second flood; the 10-member load figure is still owed and now has a clean form to run in.
## 10. 7 October 2026: pool-0 finished, TLS, and the open pool (mission item 11)
Branch `pool-finish` on `release-0.3.19` 44eee05b with the four daemon commits of `pool-v0-rebase` cherry-picked
(the node fork pinned for the pool work is 0.3.19, whose kaspa-pow needs the latency ladder's igneum-pow; master
b92a5fd4 does not carry it, so the branch sits on the release tree); fork branch `pool-finish-node` on
`release-0.3.19-node` dc141409 with `pool-v0-rebase` merged (the pool-mode miner). Ordered by the project lead on 7 October 2026,
10:1x UK, built in the order of mission 2.11: pool-0, TLS and the page rows, the share sidechain.
### 10.1 Pool-0
What 2.11 asks of pool-0 was in v0 (section 2): the member's vote key in every header, the pool with no key and no
vote, PPLNS, a payout round every 60 s, a 1 percent fee. What was missing was the fee published beside the dev fee
(reinvent 3.5): `welcome.share_scheme` now carries `software_dev_fee_percent` (0 in pool mode: the pool's fee is the only
fee), the page's Fees row says both, and `site/miner.html`'s dev-fee card says pool-0 charges the same 1 percent so solo
and pool cost the same and the choice is about variance alone. The jobs and seeds lines carry the latency ladder's rung
(`shadow_reps`) beside the class and the era, which 0.3.19's program needs; a member checks it against its own node as it
checks the seed, the class and the era (a pool that could choose the rung could choose the program).
### 10.2 TLS (spec 9.3, O-9.7 closed, Q67)
`pool/src/tls.rs`: rustls 0.23 with ring, TLS 1.3 only. `--tls-cert`/`--tls-key` (a chain from a trusted root) or
`--tls-self-signed` (a P-256 pair made under the data dir on first start, read back afterwards; the pin
`BLAKE2b("igneum-pool-cert-pin-v1" || DER)` printed at start, shown on the page and in `/api/stats`). The member
(`igneum/miner/src/pool.rs`, fork): `--pool-tls` (the Mozilla roots, the pool's host as the server name) or `--pool-pin
<hex>` (a verifier that accepts exactly the pinned certificate). The binding: both sides export 32 bytes with the label
`EXPORTER-igneum-pool-binding` and the chain id (8 bytes LE) as the context; the member signs `igneum-pool-binding-v1/
|| chain id LE || exporter` under its vote key with its own tag (`DST_BINDING`, `consensus/core/src/finality.rs`); the
pool verifies it against the member's key and refuses the `authorize` with `bye` otherwise. Tests: `finality.rs`
(the signature holds for one exporter and one chain id, is no proof of possession); `tls.rs` (a self-signed pair read
back with one pin, a pinned handshake, both sides' exporters equal, the binding verified on its own connection and
refused on a second, a wrong pin never handshakes). Testnet and mainnet refuse to start in the clear without
`--allow-plain`; the devnet's local daemons stay plain. HiveOS: `pools://` or `POOL_PIN=` (`packaging/hive`).
### 10.3 The page rows (polish Q68 to Q73)
| Row | Done |
|---|---|
| Q68 | the page's node line: "node ok, <version>", "node syncing", "no template", "node unreachable since <time>"; `/api/stats` `pool.node_state` |
| Q69 | one formatter set in the page: en-GB separators on every count, hash rate to one decimal on a kH to PH ladder, difficulty one spelling, IGN 4 decimals on tiles and 6 in tables; luck in MiningPoolStats' convention (expected over actual, over 100 percent is lucky), stated in the API source line |
| Q70 | the hashrate samples and the check costs are persisted in `state.json` (one snapshot interval, 15 s, is the loss window; the README says so); a restart keeps the rate tiles and the luck |
| Q71 | the payments of a looked-up address under its workers |
| Q72 | hourly buckets per address kept 7 days (`history` in `/api/miners/<address>`, a sparkline on the page); `--alert-webhook` POSTed once per address silent for 10 minutes; `/metrics` Prometheus text; `/health` 200 only when the node is synced and answered a template in the last 30 s |
| Q73 | the network's finality state from `getFinalityCheckpoints` (active, paused, window filling, unknown) with the latest locked index and its age, on the page and in `/api/stats`, beside the sentence "payouts follow blue confirmation, not finality" (`network.payout_rule`) |
### 10.4 The open pool: the share sidechain with no operator
The design is spec 09 section 9.12 (written with the code). In one paragraph: the member runs its own node and
`igneum-pool --open` beside it; the daemon holds no key, builds the member's templates from the member's node with the
member's key in the header and the member's own address in the coinbase, stamps the share chain's parent (`IGNS`) and
the window's split (`IGNP`) into every coinbase, re-stamps without a node call when the chain tip moves
(`restamp_extra_data`: the coinbase payload rewritten and the merkle root re-derived as the body check derives it), and
gossips shares with the other members' daemons. A share is a template at the chain's target; a block is a share at the
block target; the executor pays a blue block's producer share by its split from `pool_split_activation_daa` on
(`evm::split_producer`: integer parts by weight, the dust to the finder), so every node computes the credits from the
block alone, as it does the 20 percent. The chain: one parent per share, heaviest work wins, a fork's loser is stale and
pays nothing, a 10-second target by default held by a 30-share retarget (the DAA's shape: the window's mean target times
actual over expected span, clamped x4 per share and ten doublings above the first target; the first version compounded
the last target by the window's ratio at every share and ran to the saturation cap in thirty shares, every hash a share:
the first smoke run), a first target that is a chain constant (the network's genesis block target eight times easier,
never a node's current template, which differs between members), a PPLNS window of 2,160 shares including the share
itself, the dev fee as one split entry (the weights scaled by 2^32 before the fee's division, or a genesis share's few
dozen hashes of work rounded the dev entry to a third of its share: the unit test).
| File | What |
|---|---|
| `consensus/core/src/evm.rs` (fork) | `IGNS` and `IGNP` encode and parse, `split_producer`, the switch `install_pool_split_activation` |
| `consensus/core/src/config/params.rs` (fork) | `pool_split_activation_daa`: never by default, in the digest once set, the fees' pattern; the override test |
| `igneum/exec/src/executor.rs`, `service.rs` (fork) | `SegmentBlock.split`; the reward loop pays by the split at or above the switch; the test with an odd subsidy (the dust) |
| `kaspad/src/daemon.rs` (fork) | installs the switch from the params beside the fees |
| `pool/src/sidechain.rs` | the share, the chain, the target, the window's split, fork choice, `verify-share` |
| `pool/src/open.rs` | the daemon side: stamping, accepting (structure, the seeds, the PoW), the shares log, orphans waiting for a parent, the API. The seeds check cost two gate runs on the box: the first keyed the node's epochs by `DAA / epoch_blocks` (not how the node numbers them: every share past epoch 1 refused), the second required the daemon's own node's seed exactly, and four nodes on the 60x profile named three different seed blocks for epoch 1 with no node refusing any block (a node checks a block's PoW under the seed of the block's own ancestry); the rule now is the node's seed, or a block the node holds at the epoch's seed depth with the epoch's class, era and rung |
| `pool/src/p2p.rs` | the gossip: hello, share, get_shares, the relay, the sync when a peer is ahead |
| `pool/tools/open-gate.mjs` | the gate |
What the node fork needs merged (for the shipper, 0.3.20): the three commits of `pool-v0-rebase` (the pool-mode miner,
already on `pool-finish-node`), plus `pool-finish-node`'s own: the binding in `finality.rs`, the split codec and switch in
`evm.rs`, the params field (the digest moves only once the switch is set, so the live devnet's digest does not move), the
executor's split, the miner's TLS and rung. Nothing activates on the live devnet: `pool_split_activation_daa` stays never
until a cut sets it through the P2 mechanism or the override, as the fee switch was.
### 10.5 The gate
Run: `node pool/tools/open-gate.mjs` on igneum-build-1 from `/srv/builds/igneum-wt-pool-finish` with the box-built
binaries (the standing rule of 7 October 2026 afternoon: nothing builds or runs on the Mac; the first attempt ran on
the Mac at load 113 and the Mac crashed under it, section 10.4's retarget note): 4 nodes on the 60x fast-time profile
with real proof of work at `genesis_bits 0x1e400000`, the genesis dataset at 2^24 words (64 MiB per process against the
devnet's 1 GiB, so 100 CPU members, 10 daemons and 4 nodes fit 64 GB), `pool_split_activation_daa 0`; 10 open daemons
(one per node in turn, peered in a ring with two chords, the last one withholding its members' shares); the nodes
started one after another with `--addpeer` and their peer counts read by `getConnectedPeerInfo` before anything else
starts (the first box run started four nodes at once with `--connect`, nodes 2 and 3 never peered, and the one DAG was
three partitions with three epoch seeds, which the share chain's seeds check then reported as "not the node's": the
harness verifies the chain-side fact now, the standing rule); 100 CPU members
(one key and one address each, one thread each, niced); the chain at 0.1 s per share (at 1 block a second a 10-second
chain is sub-block work only for a pool under a tenth of the network, spec 9.12 item 8), window 2,160.
Run 1 of record: 7 October 2026, 12:53:51Z to 13:09:09Z on igneum-build-1, pool binary from pool-finish d48d9dd2 (the
seeds-check line of the last fix overlaid), node and miner from pool-finish-node b0444f51 (`strings igneumd` carries
b0444f51; the box's 96 threads also carried the proving lane's builds, load 50 to 60). Summary:
`/srv/builds/igneum-wt-pool-finish/pl-gate-1/run/summary.json` and `gate.log` (prefix pl-*, spared).
| Number | Value | Source |
|---|---|---|
| Nodes, peers, DAA at the end | 4 nodes, peers 1/2/2/1, DAA 953/953/953/953 (one DAG; the 30-s samples agree throughout) | `getConnectedPeerInfo`, `getBlockDagInfo` on every node |
| Members, daemons | 100 keys and addresses on 10 daemons; daemon 9 (members 9, 19, ..., 99) withheld its shares | the harness |
| Honest members with a share, paid by the coinbase rule | 90 of 90, 90 of 90 | the daemons' `/api/open/shares`; `eth_getBalance` before and after on node 0 |
| Members paid over time | 53 at 30 s, 71 at 60 s, 84 at 90 s, 91 at 120 s, 98 at 180 s, 100 at 211 s | the 30-s samples |
| First payout after the first share, honest members | n 90, p50 3.6 s, p90 7.0 s, max 8.3 s; 90 of 90 under 120 s | the 5-s watcher: the member's first share's header time against its first chain credit |
| Blocks found | 948 on the chain (DAA 948 at the last sample); 864 by honest daemons, 84 by the withholder | the daemons' `/api/blocks`; `getBlockDagInfo` |
| Honest blocks paying a withheld address | 0 of 864 | the honest daemons' block splits |
| Withheld shares seen by any honest daemon | 0 | the honest daemons' `/api/open/shares` |
| Withheld members' credits | 0.01 to 0.81 IGN each, from their own daemon's 84 blocks alone (that daemon pays its own window) against 2,383 IGN credited in all | `eth_getBalance` |
| Share chain at the end | height 1,814 on all nine honest daemons (1,816 on the withholder, its own branch), 2,007 shares accepted each, 193 stale, 8 to 15 reorgs, 0 refused | `/api/open` on every daemon |
| Stale rate, honest daemons | 9.6 percent (193 of 2,007) at 2 shares a second across 10 daemons on one box | `/api/open` |
| Stale rate, the withholder | 17.8 percent (its branch loses to the nine) | `/api/open` on daemon 9 |
| Share check cost, daemon 0 | p50 3.8 ms, p99 7.3 ms, max 10.2 ms over 1,044 checks (the box under other lanes' builds, nice 15 miners) | the daemon's STATUS line |
| Drop proof | `verify-share` on daemon 0's first logged share: `SHARE OK 530b509c... height 7 ... (work 32,768 hashes; cache 2^24 words)`, exit 0; on the 12-member smoke the same tool printed `BLOCK PAYS <address> weight 970,456,567 of 4,294,967,292 (22.60%)` against the first confirmed block after it | `igneum-pool verify-share` |
| Verdict | PASS | the harness |
Two runs before it failed and taught: the first (12:16Z) refused every share past epoch 1 because the seeds check keyed
the node's epochs by `DAA / epoch_blocks`; the second (12:34Z) refused shares from members on other nodes because four
nodes named three epoch seeds, and the reason was the harness, not the chain: `--connect` raced the earlier node's
listener and nodes 2 and 3 never peered (three partitions). The seeds check is now the rule of spec 9.12 item 3, and the
harness starts the nodes in sequence with `--addpeer` and reads their peers before anything else starts.
Consequences by tier, from these numbers (the standing rule of 5 October):
| Tier | What the gate's numbers mean | What is done |
|---|---|---|
| A home miner with one card (8 to 32 GB, any vendor, any OS) on the open pool | the first payout follows the first block found on the chain after its first share: 3.6 s median here at 1 block a second with the pool as the whole network; on the devnet at 100 GH/s with the open pool at a tenth of the network it is the pool's block interval, about 10 s, plus the share's wait; the member's share wait is the chain's interval times the member count over its hash share (100 members at 0.5 s: 50 s; at the spec's 10 s per share with 100 members of one card: 17 minutes, which is O-9.10's row) | the chain's rate is a chain constant a public open chain sets from its hashrate (spec 9.12 item 8, O-9.10); the gate ran at 0.5 s |
| The same card on pool-0 | unchanged by this round: a share every 10 s and a payout round every 60 s | TLS on the member port, the page rows |
| A rig | one open daemon per rig beside its node; the daemon's cost is the share checks (3.8 ms each here) at the chain's rate, under a core at 2 shares a second | nothing more |
| A pool user without a node | cannot join the open pool; pool-0 as before | the page says so |
| The network | the open pool's stale rate was 9.6 percent at 2 shares a second with ten daemons on one host; a public chain at the same rate across the internet loses more to forks, which is the uncles row (10.6); every block's coinbase grew by 48 bytes per payee, at most 256 payees, under the devnet's 16,384-byte cap | the uncles row stays open; the public chain's rate is O-9.10 |
### 10.5a The ten-member window on pool-1 (the fleet lane, 7 October 2026, 12:37Z to 13:30Z)
Ten rented RTX 3070 members (`mine none`, no node of their own) on the fixed pair, daemon 03457d96 and miner 9829bdf7,
pool-1's node 5899f603, plain TCP. Result: 10 of 10 authorized, 0 shares, 0 blocks, 0 payouts, every member's vardiff
easing from shift 10 to 27 with nothing to measure (bundle `~/igneum-fleet/pool-run-1328/` on the fleet lane's side). Two
faults stacked, each its own row:
| Fault | Side | What the logs show | Fix |
|---|---|---|---|
| The daemon got no template after 12:54Z | daemon (this lane) | 1,891 "template for member N: RPC request timeout" lines, STATUS "NODE NOT ANSWERING: last template 2,072 s ago" at 13:28Z, while the node read synced with 3 to 4 peers and answered its own miner's 578 template requests | ten parallel `getBlockTemplate` calls on one gRPC connection against a node that builds a template in 1.6 to 1.8 s queued past the client's own request timeout; `--template-parallel` (default 2) bounds the fetches in flight, the rest take the next tick |
| The members' NVRTC prepare never completed across the epoch boundary 78 to 79 | the member's worker path | every member printed `prepare started ... (NVRTC sm_86 in the background)` and never `prepared` or `prepare-failed`; the worker sat at 0 percent on the old pack; a solo miner on a standing box crossed the same boundary through its exit-42-and-re-export path, which the member path lacks | the register's row (docs/plans/miner-faults.md, the reliability lane's number): a member that hears nothing for 120 s after a prepare treats it as failed, re-exports, re-sends once, then restarts its worker with the reason shown; the pool flags a member with 0 accepted across an epoch roll |
So the window holds no pool-0 share numbers; its rerun waits for both fixes on the 0.3.21 pair.
### 10.6 Open after this round
| Item | Why it is open | What closes it |
|---|---|---|
| Uncles | a fork's loser is stale; on the public internet at 100 ms between members a 10-share-a-second chain would lose several percent of shares to forks | P2Pool's uncle rule (a share may name up to N recent stale shares; they are paid at a discount), after the stale rate of a public chain is measured |
| Share bodies | a share carries the full template; on a chain with full blocks that is the block's size per share | the transaction ids plus the coinbase, the merkle root rebuilt from them (mode B's shape) |
| A late joiner | a share of an epoch the member's node no longer reports cannot be checked, so the sync is bounded to those epochs (O-9.11) | a node RPC for past epochs' seeds, or the seeds in the share chain's headers |
| The chain's rate | the public chain's seconds per share against its hashrate (O-9.10) | one chain per decade of hashrate (P2Pool's main, mini, nano), or uncles |
| The Hive package, the app | neither starts an open daemon yet; the app's "mine to a pool" setting (section 7) is still design | the app spawns `igneum-pool --open` beside its node when the setting says open |
| Vote relay and mode C | unchanged from section 3 | unchanged |
### 10.7 Consequences by tier
| Tier | Pool-0 | The open pool |
|---|---|---|
| A home miner on one card (8, 12, 16, 24, 32 GB; NVIDIA, AMD, Apple; Windows, Linux, macOS) | nothing changes on the card; the member port is TLS and the miner's `--pool-pin` or `--pool-tls` is one flag; the 1 percent is the same money as solo's dev fee | runs its own node (the app already does) and one more process, the open daemon, whose memory is the day cache (1 GiB on the devnet, as the miner's); the 1 percent is the split entry; the first payout follows the first block found on the chain after its first share, seconds at devnet scale; at a public chain's rate its share interval is the chain's rate times the members' count, the row O-9.10 is about |
| A rig | one daemon per rig, every card's miner on it; one key per operator as before | the same |
| A pool user without a node | pool-0 as before (hashes, does not vote) | cannot join the open pool: the daemon builds templates from a node; a user without one takes pool-0 |
| The pool operator (pool-0) | TLS on the member port, `/metrics`, `/health`, the alert webhook, the persisted ledger | none exists |
| The network | pool concentration is not vote concentration, as before; an open pool's blocks carry as many keys as it has members and pay as many addresses as the window holds, so the coinbase grows by 48 bytes per payee (at most 256) | the executor's split is the one new consensus-visible rule, behind a switch that is never on the devnet |

View file

@ -1,6 +1,6 @@
# Igneum protocol specification, section 9: the pool protocol
Spec version 0.1, 3 October 2026. Status of this section: Designed. Nothing here is implemented. The only miner-to-worker protocol that exists is the devnet worker protocol (`igneum-miner --worker`, `vendor/igneum-node/igneum/miner/src/main.rs`; `--serve` in `proto-cuda/host.cu`, `proto-opencl/host.c`, `proto-metal/main.swift`), which runs between two processes on one machine and is cited here where this section reuses it. The review of 3 October 2026 found that no pool protocol existed and that "shares on a 64-bit lane hash with vardiff are unspecified" (`docs/review/round-3-2026-10-03.md`, farm operator, attack 3, R3.15). This section is the answer.
Spec version 0.2, 7 October 2026 (0.1 of 3 October 2026). Status of this section: Implemented in part. The reference pool (`pool/`, docs/plans/pool.md) implements 9.3 (TLS 1.3 and the binding, 7 October 2026), 9.4 mode A, 9.5, 9.6, 9.8 and 9.9; 9.7 (vote relay) and modes B and C are designed only; 9.12 (the open pool, the share sidechain with no operator) is implemented and gated on a fast-time network (pool.md section 10). The original note stands for what is still only designed. The only miner-to-worker protocol that exists is the devnet worker protocol (`igneum-miner --worker`, `vendor/igneum-node/igneum/miner/src/main.rs`; `--serve` in `proto-cuda/host.cu`, `proto-opencl/host.c`, `proto-metal/main.swift`), which runs between two processes on one machine and is cited here where this section reuses it. The review of 3 October 2026 found that no pool protocol existed and that "shares on a 64-bit lane hash with vardiff are unspecified" (`docs/review/round-3-2026-10-03.md`, farm operator, attack 3, R3.15). This section is the answer.
Lineage, credited. The shape is Stratum V2's (Braiins and the Stratum V2 working group, stratumprotocol.org), from memory and approximate: a binary, encrypted protocol with a Mining Protocol for shares, a Job Declaration Protocol under which a miner builds its own block template from its own node and declares it to the pool, which may accept it and must then pay shares found on it, and a Template Distribution Protocol between the miner and its node. Igneum keeps the three ideas (encrypted transport, miner-built templates under the pool's payout, pool pays by shares) and changes what the header carries: on Igneum the header names a vote key (section 2.4) and the vote key decides finality (section 3), so this section also fixes whose key a pooled block names and who signs the checkpoint votes. The ledger entries F10 and G6 (pools hold the votes; Stratum V2 job declaration is optional) are the two criticisms this section is written against.
@ -46,7 +46,9 @@ Why. The pool protocol crosses operators and languages: pools, mining operating
| Integers | JSON numbers up to 2^53; anything that can exceed it (nonces, targets, hashes, weights in base units) is a hex string | Designed |
| Identifiers | `id` on every request, echoed on the reply; notifications carry no `id` | Designed |
| Port | 4461 (the mainnet chain id, section 7.4), 4462 testnet, 4463 devnet; a pool MAY use another and say so in its address | Designed |
| Member authentication | The `authorize` message carries the member's BLS public key and a proof of possession (the `KeyReveal` of section 3.10, 144 bytes), plus a signature over the session's TLS exporter value under the same key, so the key proves it is live and the connection is bound to it | Designed |
| Member authentication | The `authorize` message carries the member's BLS public key and a proof of possession (the `KeyReveal` of section 3.10, 144 bytes), plus `binding`: a signature over the session's TLS exporter value under the same key, so the key proves it is live and the connection is bound to it | Implemented (7 October 2026, O-9.7 closed below) |
| The binding's bytes (O-9.7) | Exporter: TLS 1.3 `export_keying_material` with label `EXPORTER-igneum-pool-binding`, context = the chain id as 8 little-endian bytes, 32 bytes out, on both sides. Message: `igneum-pool-binding-v1/` \|\| chain id (8 bytes LE) \|\| exporter. Signature: BLS12-381 G2 under the tag `IGNEUM_POOL_BINDING_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_` (never the vote or the PoP tag; a binding is not a vote). The pool refuses an `authorize` whose binding does not verify for THIS connection's exporter with `bye`; a replay on another connection carries another exporter and fails. Over plain TCP the field is empty and ignored (devnet only; testnet and mainnet pools refuse plain without `--allow-plain`) | Implemented (`consensus/core/src/finality.rs` binding_message, sign_binding, verify_binding; pool `tls.rs`; miner `pool.rs`) |
| Server identity | A chain from a root the member's system trusts (`--pool-tls`), or a pinned certificate (`--pool-pin <hex>`: BLAKE2b of `igneum-pool-cert-pin-v1` \|\| the DER certificate, the pin a self-signed pool prints at start and shows on its page) | Implemented |
## 9.4 Templates
@ -87,16 +89,16 @@ Direction P = pool, M = member. Every message is one object with `"t"` naming th
| Type | Direction | Fields | Meaning |
|---|---|---|---|
| `hello` | M to P | `versions` (list), `chain_id`, `client`, `modes` supported | Opens the session |
| `welcome` | P to M | `version`, `chain_id`, `pool_address`, `modes`, `vote_mode` (`member` or `pool`), `declaration` {`tag`, `max_extra_bytes`, `max_declarations_per_s`, `declare_timeout_ms`}, `share_scheme` (`pplns` or `pps` with its parameters), `min_shift`, `max_shift` | The pool's terms; the member shows `vote_mode` and `share_scheme` to its operator |
| `welcome` | P to M | `version`, `chain_id`, `pool_address`, `modes`, `vote_mode` (`member` or `pool`), `declaration` {`tag`, `max_extra_bytes`, `max_declarations_per_s`, `declare_timeout_ms`}, `share_scheme` (`pplns`, `pps` or `pplns-sidechain` with its parameters, and `software_dev_fee_percent`: the software fee a member pays in this mode, published beside the pool fee), `min_shift`, `max_shift`, `pool_mode` (`operator` or `open`, 9.12), `tls` | The pool's terms; the member shows `vote_mode` and `share_scheme` to its operator |
| `authorize` | M to P | `pubkey` (48 bytes hex), `pop` (96 bytes hex), `binding` (signature over the TLS exporter), `label` | Names the member by its vote key |
| `authorized` | P to M | `member_id`, `revealed` (true when the key has been revealed on chain, per the pool's node) | |
| `seeds` | P to M | `epoch_seed`, `day_seed`, `seed_source`, `vdf_proof` (516 bytes hex, section 4.4, when the member asks), `next_epoch_seed`, `next_day_seed`, `next_at_daa` | Current and next seed pair so the member can `prepare` its workers ahead (the worker protocol's `prepare`) |
| `seeds` | P to M | `epoch_seed`, `day_seed`, `seed_source`, `vdf_proof` (516 bytes hex, section 4.4, when the member asks), `next_epoch_seed`, `next_day_seed`, `next_at_daa`, `program_class`, `next_program_class`, `era_seed`, `shadow_reps`, `next_shadow_reps` (the program class, era seed and latency-ladder rung of the current and the next epoch: a member refuses a job whose program it cannot name) | Current and next seed pair so the member can `prepare` its workers ahead (the worker protocol's `prepare`) |
| `set_target` | P to M | `shift` s (9.8) | Vardiff |
| `template` | P to M | `template_id`, `mode` (A or B), `header` (every field but nonce, hex), `coinbase`, `transactions` (A) or `merkle_path` plus `tx_count` (B), `votes_carried` (indices of the member's votes in this template) | A new template; supersedes the previous one for new jobs |
| `declare_template` | M to P | `header`, `coinbase`, `transactions` | Mode C |
| `template_ack` | P to M | `template_id` | |
| `template_refused` | P to M | `code` in {`invalid`, `payout`, `tag`, `oversize`, `rate`, `capacity`, `other`}, `detail` | |
| `job` | P to M | `job_id`, `template_id`, `prehash` (64 hex), `target64` (16 hex), `share_target64` (16 hex), `nonce_start` (hex, a multiple of 32), `nonce_count` (hex, a multiple of 32), `clean` (bool) | Work. `clean` true means abandon earlier jobs. The member forwards it to its workers as a worker-protocol `job` line with `share_target64` in the target field |
| `job` | P to M | `job_id`, `template_id`, `prehash` (64 hex), `target64` (16 hex), `share_target64` (16 hex), `nonce_start` (hex, a multiple of 32), `nonce_count` (hex, a multiple of 32), `epoch_seed`, `day`, `program_class`, `era_seed`, `shadow_reps`, `clean` (bool) | Work. `clean` true means abandon earlier jobs. The member forwards it to its workers as a worker-protocol `job` line with `share_target64` in the target field; its CPU re-check hashes exactly the program the job names |
| `job_refused` | M to P | `job_id`, `code` in {`vote_key`, `payout`, `reveal`, `votes`, `header`, `merkle`, `invalid`, `seeds`}, `detail` | The member will not hash this job and says why |
| `share` | M to P | `job_id`, `nonce` (16 hex), `hash` (16 hex) | A lane hash at or below `share_target64` |
| `share_result` | P to M | `job_id`, `nonce`, `accepted` (bool), `code` in {`ok`, `stale`, `duplicate`, `above_target`, `wrong_hash`, `unknown_job`}, `weight` (the share's weight, 9.8) | |
@ -152,11 +154,27 @@ The program changes every epoch and the dataset every day (section 1.12), and an
2. The member MUST check `epoch_seed` and `next_epoch_seed` against its verifier (the VDF output for the epoch, section 4.3; on the devnet, the hash of the last selected-chain block below `3,600 e - 600`) and refuse jobs on a seed its verifier does not confirm (`job_refused`, code `seeds`). A pool that could choose the seed could choose the program, which is the grinding section 4 exists to prevent.
3. A member whose verifier is the light client of section 10 verifies the VDF proof (516 bytes, 4.5 ms, Measured, section 4.4) that the pool forwards in `seeds`, against the `seed_source` its checkpoint chain confirms.
## 9.12 The open pool: a share sidechain with no operator
Added 7 October 2026 (mission item 11; docs/plans/pool.md section 10). Lineage: Monero P2Pool (SChernykh, 2021: a 10-second share sidechain merge-mined with the main chain, PPLNS over the window, payouts as coinbase outputs, no operator; from its README, approximate), which this section rebuilds on three objects Igneum already had: the vote key in the header (9.6), the coinbase extra data (section 3.10's key reveal and the devnet's `IGNA` address), and an execution layer that pays by rule from consensus data (the 20 percent proving share). Nothing here changes block validity; the one consensus-visible rule is the executor's split (item 5).
1. A member of the open pool runs its own node and the open daemon beside it (`igneum-pool --open`). The daemon serves this section's member protocol to the member's own miners, builds every template from the member's node with the member's key in the header and the member's own payout address in the coinbase (`IGNA`), and holds no key and no balance.
2. A share is a template at the share chain's target: a lane hash at or below `share_target64` of the chain, with the coinbase extra data carrying, beside the reveal and `IGNA`, the share it extends (`IGNS` \|\| 64 hex: the parent's block hash, the zero hash for a genesis share) and the window's payout split (`IGNP` \|\| 4 hex count \|\| count x (40 hex address \|\| 8 hex weight), `kaspa_consensus_core::evm`). A share's identity is its block hash. A share whose lane hash is also at or below the block target is a block, submitted to the member's node as any block.
3. The chain. One parent per share; the heaviest cumulative work (the sum of `2^64 / target` over the ancestry) is the tip; a share off the tip's ancestry is stale and pays nothing; a share extending more than 256 heights below the tip is refused. Target: `chain_share_s` seconds per share (10 by default), held by the Kaspa DAA's shape over the last 30 shares (the window's mean target times its actual span over its expected span, header timestamps), clamped to a factor of 4 against the parent's target, never above 2^62 nor ten doublings above the chain's first target; the first target is a chain constant (`--chain-genesis-target64`, by default the network's genesis block target eight times easier; never a node's current template, which differs between members). Members gossip shares (`p2p.rs`: `hello`, `share`, `get_shares`, newline JSON); every share is checked by every member: the parent known, the height, the target the chain fixes after the parent, the block hash and the body's merkle root, the reveal's key in the header, `IGNA` the share's address, `IGNS` the parent, `IGNP` byte-equal to the split the chain computes for that parent and that address, the seeds either those the member's own node reported for the share's epoch or (another node's view of the same epoch, which a DAG allows while the chain below the lead settles) an epoch seed that is a block the member's node holds at that epoch's seed depth with the epoch's class, era and rung, and the PoW under those seeds. Nodes never see a share.
4. The split. For a share by address A at target T extending parent P: the last `window_shares - 1` shares ending at P plus the share itself, weighed by `2^64 / target`, summed per address; the software dev fee as one more entry at `open_dev_fee_percent` of the whole (the same 1 percent the solo miner pays, so solo, pool-0 and the open pool are fee-neutral); scaled to u32 weights of 2^32; descending by weight then ascending by address; at most 256 entries (the smallest dropped past it). Every member of a chain holds the same `window_shares`, `chain_share_s`, fee and dev address, or its shares mismatch on the split and are refused: these four are the chain's constants, named by its `--open-chain` string.
5. Payment (the consensus-visible rule, `igneum/exec/src/executor.rs`): from `pool_split_activation_daa` on (a params field, in the digest once set, never by default; the fast-time gate ran it at 0), a blue block whose coinbase carries a well-formed `IGNP` has its producer share paid by the split: each entry `floor(share x weight / sum)`, the rounding dust to the block's `IGNA` address; a block without a split, below the switch, or with a malformed split pays its `IGNA` address alone, as every block does today. The 20 percent proving share is untouched. So a block found on the share chain pays the window from its own coinbase, every node computes the same credits from the block alone, and nobody holds anything for anybody.
6. What a withheld share earns: nothing. A share kept from the gossip is in no other member's chain, so no block but the withholder's own names it in a split, and the withholder's own blocks pay the others' shares by the same rule. Dropping another member's shares is the same act and costs the dropper the same.
7. The drop proof. Every share a member finds is written to its own `shares.log` as one self-certifying line (the seeds, the header with its nonce, the coinbase): `igneum-pool verify-share <line> [--block <raw block>]` re-hashes it, and against a block says whether that block's split pays the share's address (exit 3, `BLOCK DROPS`, when it does not).
8. What is not here yet (pool.md section 10.6): uncles (a fork's loser is stale; the gate measures the stale rate), share bodies as transaction ids instead of the full template, a late joiner's verification of shares from epochs its node no longer reports, and the chain's share rate against the network's: at 1 block a second a 10-second share chain is sub-block work only for a pool under a tenth of the network, so the gate ran the chain at 0.1 s per share and a public open chain will set its rate from its hashrate (the mini and nano chains of Monero P2Pool are the same answer).
## 9.10 Parameters in this section
| Parameter | Value | Label |
|---|---|---|
| Transport | TLS 1.3, newline-delimited JSON | Designed |
| Transport | TLS 1.3, newline-delimited JSON | Implemented (7 October 2026); plain TCP allowed on the devnet only |
| Binding | `igneum-pool-binding-v1/` \|\| chain id LE \|\| 32-byte exporter (`EXPORTER-igneum-pool-binding`, context chain id LE), tag `IGNEUM_POOL_BINDING_V1_...` | Implemented |
| Open pool: `chain_share_s`, `window_shares`, `open_dev_fee_percent`, split entries, first target | 10 s, 2,160, 1 percent, at most 256, `target64(genesis bits) << 3` | Implemented (9.12); the public chain's rate is open, 9.12 item 8 |
| Open pool: `pool_split_activation_daa` | never by default; 0 on a new chain; in the digest once set | Implemented (params, executor) |
| Ports | 4461 / 4462 / 4463 | Designed |
| Required modes | A (full template) and C (declared); B optional | Designed |
| `declare_timeout_ms` | 500 | Designed, Open O-9.5 |
@ -184,6 +202,8 @@ The program changes every epoch and the dataset every day (section 1.12), and an
| O-9.4 | Mode A item 5 needs a node RPC that validates a block as a template without submitting it; none is named | Add the RPC to the fork (candidate: `validateBlockTemplate`), with the cost per call measured at the design document's gas budgets | 2 |
| O-9.5 | Every timing here is Designed: template size and bandwidth per member at 1 BPS, the member's check cost (9.4.1), `declare_timeout_ms`, `share_interval_s`, the pool's verification throughput on a server core | A reference pool with 100 members on the phase 4 devnet: record template bytes per second per member, check time per template on a 2019-class laptop core, declared-template acceptance latency, shares verified per second per core on a server CPU; set the four parameters from the distributions | 4 |
| O-9.6 | The carriage ratio of 9.7 item 6 has no threshold, and its 60-block window is a guess | On the same devnet, one pool conforming and one dropping every vote: record both ratios per key over a day; set the warning threshold at the point that separates them with no false warnings on the conforming pool | 4 |
| O-9.7 | The TLS exporter binding in `authorize` is named and not specified (which exporter label, which bytes are signed) | Write the exact bytes at the first implementation; test that a replayed `authorize` on a second connection is refused | 4 |
| O-9.7 | CLOSED 7 October 2026: the exact bytes are in 9.3 (label, context, message, tag); `pool/src/tls.rs` tests a replayed `authorize` on a second connection refused, and `finality.rs` tests the signature bound to one exporter and one chain id | Written at the first implementation, as asked | 4 |
| O-9.10 | The open pool's share rate against the network's (9.12 item 8): a 10-second chain is sub-block work only for a pool under a tenth of a 1-block-a-second network; the gate ran at 0.1 s per share | A public open chain names its rate from its hashrate (one chain per decade of hashrate, the Monero P2Pool answer) or carries uncles; decide at the first public open chain | 4 |
| O-9.11 | A late joiner of the open pool cannot verify shares of epochs its node no longer reports (`Open::seeds_for`), so its sync is bounded to those epochs | A node RPC that answers the seeds of any past epoch, or the epoch seeds carried in the share chain's own headers, before a chain older than the node's epoch window is public | 4 |
| O-9.8 | A member with only the light client of section 10 as its verifier checks headers against a checkpoint chain, not against a full node's tip, so item 4 of 9.4.1 (parents are known tips) is weaker for it: it can confirm the parents descend from the last certified checkpoint and no more | Decide at gate 4 whether a light-client member may vote (9.7 item 2 says yes) after the eclipse test of O-3.7 is re-run with light-client members in the model | 3, with 4 |
| O-9.9 | HiveOS and the rental markets need this protocol to list the algorithm (ledger entry on rental, "cannot list an algorithm whose kernel changes hourly without a stratum for it") | The reference member runs under HiveOS against the reference pool through 24 epoch changes with in-worker compilation (R3.15's acceptance test: outage under 2 s per change) | 4 |

View file

@ -63,6 +63,7 @@
"latency_ladder_activation_daa": 18446744073709551615,
"latency_ladder_window_daa": 120,
"proving_v1_fresh_rule_daa": 18446744073709551615,
"pool_split_activation_daa": 18446744073709551615,
"exec_restart_number": 18446744073709551615,
"exec_restart_hash": "",
"exec_restart_state_root": "",

View file

@ -97,10 +97,12 @@ if [[ "$IDENTITIES" == "auto" ]]; then
[[ $n == 0 ]] && ident_summary="no VRAM readable, 8 per card"
fi
url="$CUSTOM_URL"; pool_url=""
if [[ "$url" == pool://* || "$url" == stratum+tcp://* ]]; then
# pool mode: the Flight Sheet's pool URL is the pool; the node (if any) comes from NODE=
pool_url="${url#pool://}"; pool_url="${pool_url#stratum+tcp://}"; pool_url="${pool_url%/}"
url="$CUSTOM_URL"; pool_url=""; pool_tls=0
if [[ "$url" == pool://* || "$url" == pools://* || "$url" == stratum+tcp://* || "$url" == stratum+ssl://* ]]; then
# pool mode: the Flight Sheet's pool URL is the pool; the node (if any) comes from NODE=. pools:// or
# stratum+ssl:// is TLS 1.3 against the system roots (spec 9.3); POOL_PIN=<hex> pins a self-signed pool's certificate
[[ "$url" == pools://* || "$url" == stratum+ssl://* ]] && pool_tls=1
pool_url="${url#pool://}"; pool_url="${pool_url#pools://}"; pool_url="${pool_url#stratum+tcp://}"; pool_url="${pool_url#stratum+ssl://}"; pool_url="${pool_url%/}"
url="${NODE:-none}"
[[ "$url" != "local" && "$url" != "none" && "$url" != grpc://* ]] && url="grpc://$url"
else
@ -123,9 +125,11 @@ PEERS=$PEERS
EXTRA=$(sq "$EXTRA")
OVERRIDE=$(sq "$OVERRIDE")
POOL_URL=$pool_url
POOL_TLS=$pool_tls
POOL_PIN=${POOL_PIN:-}
CONF
if [[ -n "$pool_url" ]]; then
echo "Igneum: config written to $CUSTOM_CONFIG_FILENAME (POOL $pool_url, verifier node $url, wallet ${wallet:0:8}..., worker $label; no software dev fee in pool mode; override ${OVERRIDE:+set}${OVERRIDE:-NONE: a local verifier node will be refused by devnet peers})"
echo "Igneum: config written to $CUSTOM_CONFIG_FILENAME (POOL $pool_url, $([[ -n "${POOL_PIN:-}" ]] && echo "TLS pinned" || { [[ $pool_tls == 1 ]] && echo "TLS" || echo "plain TCP"; }), verifier node $url, wallet ${wallet:0:8}..., worker $label; no software dev fee in pool mode; override ${OVERRIDE:+set}${OVERRIDE:-NONE: a local verifier node will be refused by devnet peers})"
else
echo "Igneum: config written to $CUSTOM_CONFIG_FILENAME (node $url, wallet ${wallet:0:8}..., dev fee ${DEV_FEE}%, identities ${ident_summary:-$IDENTITIES per card}, override ${OVERRIDE:+set}${OVERRIDE:-NONE: a local node will be refused by devnet peers})"
fi

View file

@ -23,6 +23,8 @@ trap cleanup INT TERM
# 1. the node (in pool mode, NODE_URL is "none" unless the Flight Sheet asked for a verifier node with NODE=)
POOL_URL="${POOL_URL:-}"
POOL_TLS="${POOL_TLS:-0}"
POOL_PIN="${POOL_PIN:-}"
if [[ "$NODE_URL" == "local" ]]; then
NODE_URL="grpc://127.0.0.1:26610"
peers=()
@ -84,6 +86,8 @@ run_gpu() {
if [[ -n "$POOL_URL" ]]; then
# pool mode: one vote key per operator (spec 9.6), the worker name labels the card, no software dev fee
args+=(--pool "$POOL_URL" --worker-name "$LABEL-gpu$idx")
# spec 9.3: TLS 1.3 to the pool (pools:// in the Flight Sheet), or a pinned self-signed certificate (POOL_PIN=)
if [[ -n "$POOL_PIN" ]]; then args+=(--pool-pin "$POOL_PIN"); elif [[ "$POOL_TLS" == "1" ]]; then args+=(--pool-tls); fi
else
local identv="IDENTITIES_GPU$idx" ident="$IDENTITIES"; [[ -n "${!identv:-}" ]] && ident="${!identv}" # per-card from h-config.sh, else the fallback
[[ "$ident" -gt 1 ]] && args+=(--identities "$ident")

36
pool/Cargo.lock generated
View file

@ -2748,10 +2748,14 @@ dependencies = [
"kaspa-pow",
"kaspa-rpc-core",
"rand 0.8.6",
"rcgen",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"sha3 0.10.8",
"tokio",
"tokio-rustls",
]
[[package]]
@ -3982,6 +3986,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -4418,6 +4432,19 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.7"
@ -6394,6 +6421,15 @@ dependencies = [
"tap",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "zerocopy"
version = "0.7.35"

View file

@ -36,6 +36,11 @@ alloy-primitives = { version = "1.7", default-features = false, features = ["std
alloy-eips = { version = "2.5", default-features = false, features = ["std"] }
alloy-signer = { version = "2.5", default-features = false }
alloy-signer-local = { version = "2.5", default-features = false }
# TLS 1.3 on the member port (spec 9.3): rustls with ring, the tokio adapter, PEM reading, a self-signed pair on first start
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls-pki-types = { version = "1", features = ["std"] }
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
[profile.release]
lto = "thin"

View file

@ -1,4 +1,4 @@
# igneum-pool: the Igneum mining pool, version 0
# igneum-pool: the Igneum mining pool (pool-0, and the open pool)
A public mining pool for Igneum (`docs/spec/09-pool-protocol.md`, `docs/plans/pool.md`). It talks to one or more
`igneumd` nodes, builds a template per member with the member's own vote key in the header, hands out jobs over the
@ -54,12 +54,55 @@ igneum-pool --node grpc://127.0.0.1:26610 --evm-rpc http://127.0.0.1:26790 \
| `--stale-grace-ms` | `2000` | a share on a superseded job inside the grace still pays |
| `--orphan-after-daa` | `120` | a pending block this far behind the virtual and not blue is an orphan |
| `--verify-threads` | `2` | share checks running at once on the CPU |
| `--template-parallel` | `2` | template fetches in flight against the node (one gRPC connection is one request stream; a burst of one fetch per member against a node that builds a template in over a second queued past the client's request timeout on pool-1, 7 October 2026) |
| `--tls-cert`, `--tls-key` | none | TLS 1.3 on the member port (spec 9.3) from a PEM chain and key |
| `--tls-self-signed` | off | TLS 1.3 from a self-signed pair made under the data dir on first start; the pool prints the certificate's pin, members pass it as `--pool-pin` |
| `--allow-plain` | off | plain TCP members on testnet or mainnet (the devnet allows plain by default; the public networks refuse to start in the clear without this) |
| `--alert-webhook` | none | Q72: an `http://` URL POSTed once per address that sent shares before and none for ten minutes |
| `--open` | off | the open pool: the share sidechain with no operator (below) |
| `--p2p-listen`, `--peer` | `0.0.0.0:<member port + 10>`, none | the open pool's share gossip: where to listen, who to connect to |
| `--chain-share-s`, `--window-shares`, `--open-dev-fee-percent`, `--open-chain`, `--chain-genesis-target64` | `10`, `2160`, `1`, `igneum-open-v1`, the network's genesis block target eight times easier | the share chain's constants; every member of one chain holds the same or its shares are refused |
At start the pool prints its payout address. That address is the pool's coinbase address: every template names it in
the `IGNA` field, so the execution layer credits it 80% of every blue block the pool finds. The 20% proving share goes
to the proving escrow by the chain's own rule (`consensus/core/src/igneum.rs`, `igneum/exec/src/executor.rs`); the
pool never touches it and cannot.
## TLS on the member port (spec 9.3, 7 October 2026)
`--tls-self-signed` makes `tls-cert.pem` and `tls-key.pem` under the data directory on first start and prints the
certificate's pin (`BLAKE2b("igneum-pool-cert-pin-v1" || DER)`, 64 hex); a member connects with
`igneum-miner ... --pool host:4463 --pool-pin <pin>`. A public pool with a certificate from a root the members'
systems trust runs `--tls-cert chain.pem --tls-key key.pem`, and members use `--pool-tls`. Over TLS every `authorize`
carries the binding: the member's BLS signature over this connection's TLS exporter (label
`EXPORTER-igneum-pool-binding`, context the chain id), so an `authorize` replayed on another connection is refused
(`src/tls.rs`, the test `self_signed_tls_pins_exports_and_binds_one_connection`). HiveOS: `pools://host:4463` in the
Flight Sheet for `--pool-tls`, `POOL_PIN=<pin>` in the extra config for a pinned pool. Testnet and mainnet pools refuse to
start in the clear unless `--allow-plain` says so.
## The open pool (`--open`, 7 October 2026; docs/plans/pool.md section 10; spec 09 section 9.12)
The same binary beside a member's own node, with no payout key: the member's miners connect to it on loopback (or a
rig's LAN), it builds their templates from the member's node with the member's key in the header and the member's own
address in the coinbase, stamps the share chain's parent (`IGNS`) and the window's split (`IGNP`) into every coinbase,
and gossips shares with the other members' daemons (`--peer`). A block found on the chain pays the window from its own
coinbase by the execution layer's split rule (`pool_split_activation_daa` in the node's override file; blocks below it
pay the finder alone). Nobody holds a balance; the only fee is the software dev fee as one entry of the split (the solo
miner's 1 percent).
```
igneum-pool --open --node grpc://127.0.0.1:26610 --listen 127.0.0.1:4463 --http 127.0.0.1:4480 --data-dir ~/.igneum-open \
--p2p-listen 0.0.0.0:4473 --peer other-member.example:4473 --network devnet
igneum-miner mine grpc://127.0.0.1:26610 1 100000000 me --pool 127.0.0.1:4463 --evm-address 0xYOURADDRESS --worker-name gpu0 --worker ./igneum-worker-cuda
```
Every share the daemon's own members find is one line of `<data-dir>/shares.log`, self-certifying; `igneum-pool
verify-share '<line>' [--block '<raw block json>']` re-hashes it and, against a block (its raw JSON from
`/api/open/share/<hash>`), says whether that block's split pays the share's address (`BLOCK PAYS`, exit 0; `BLOCK DROPS`,
exit 3). `/api/open` is the chain (height, tip, target, the window's split), `/api/open/shares?from=&limit=` the shares,
`/api/payments` the coinbase credits by block. The gate: `pool/tools/open-gate.mjs` (100 members, 10 daemons, 4 nodes on
a fast-time network; pool.md section 10.5 has the numbers).
## What the operator must secure
- **The payout key** (`payout-key.json`). It holds every reward the pool earns until the payout round sends it on.
@ -69,7 +112,9 @@ pool never touches it and cannot.
(4463) reachable; v0 speaks plain TCP to members, so the terminator does not cover shares yet (TLS for the member
port is the v0 gap named in `docs/plans/pool.md`).
- The node's RPC (`--rpclisten`) and EVM RPC (`--evm-rpclisten`) on loopback only. The pool talks to them locally.
- `state.json`: the ledger. It is rewritten every 15 s; a lost file loses unpaid balances since the last payout, so
- `state.json`: the ledger. It is rewritten every `--snapshot-interval-s` (15 s): balances, blocks, payments, the
hashrate samples and the check costs (Q70: a restart keeps the rate tiles and the luck; the loss window is one
interval), and the hourly history per address (7 days). A lost file loses unpaid balances since the last payout, so
back it up with the key.
## Deploy on one Hetzner box (the seed-node pattern)
@ -142,6 +187,14 @@ software takes no dev fee; the pool's fee is in the welcome line the miner print
| `/api/miners/<address>` | hashrate (10 min, 1 h, reported), shares, workers (with shift, proving flag, online), balance, paid, payments |
| `/api/payments?limit=N` | every payout with its transaction hash and receipt status |
| `/api/pool-stats` | the flat camelCase object pool dashboards poll (hashrate, miners, workers, blocks, lastBlock, fee, minPayout) |
| `/metrics` | Prometheus text (Q72) |
| `/health` | 200 when the node is synced and answered a template in the last 30 s, else 503 with the node's state (Q72) |
| `/api/open`, `/api/open/shares`, `/api/open/share/<hash>` | the open pool's share chain |
The page (Q69 to Q73): every number formatted as the site's are (en-GB separators, hash rate to one decimal on a kH to
PH ladder, IGN to 4 decimals on tiles and 6 in tables), luck in MiningPoolStats' convention (expected over actual),
the payments of a looked-up address under its workers with a 7-day hourly sparkline, the node's state in words, and the
network's finality state beside the sentence "payouts follow blue confirmation, not finality".
The field lists are constants in `src/api.rs`; `cargo test` checks the fixtures in `tests/fixtures/` against them.
Captured responses from the measured run are the fixtures.
@ -160,7 +213,12 @@ Captured responses from the measured run are the fixtures.
| `src/pplns.rs` | the window, the split, the distribution with the fee |
| `src/payout.rs` | the payout key, EIP-1559 transfers through eth_ JSON-RPC, receipts, dry run |
| `src/state.rs` | the ledger and its snapshot |
| `src/api.rs` | the HTTP server, the routes, the field contracts |
| `src/api.rs` | the HTTP server, the routes, the field contracts, `/metrics`, `/health` |
| `src/tls.rs` | TLS 1.3 on the member port, the self-signed pair, the exporter of the binding |
| `src/sidechain.rs` | the open pool's share chain: shares, the target, the window's split, fork choice, `verify-share` |
| `src/open.rs` | the open pool's daemon side: stamping templates, accepting shares, the shares log, the API |
| `src/p2p.rs` | the share gossip between members |
| `tools/open-gate.mjs` | the open pool's gate on a fast-time network |
| `web/index.html` | the page |
| `tools/measure.mjs` | the private-network measurement |
@ -168,5 +226,11 @@ Captured responses from the measured run are the fixtures.
Every Linux build and suite runs on the box through `tools/build-remote.sh` from this directory (`IGNEUM_AGENT=pool`). The
crate reads the fork through the `vendor/igneum-node` symlink; the build library syncs the fork worktree it points at as a
whole repository, and the symlink is made once on the box by hand: `ln -s <fork worktree name> /srv/builds/<worktree>/vendor/igneum-node`.
whole repository, and the symlink is made once on the box by hand: `ln -s <fork worktree name> /srv/builds/<worktree>/vendor/igneum-node`,
with the line `vendor/igneum-node` in `/srv/builds/<worktree>/.igneum-scratch-spare` so the mirror's clean before every build
keeps it (7 October 2026: without the line the first box build of the pool crate removed it and cargo found no fork).
Artefacts land in `pool/target-remote/release/igneum-pool` (x86_64 Linux); the Mac builds no Linux binary.
Start-up contract (7 October 2026): the daemon exits 2 when `--listen` or `--http` cannot be bound, exits 3 when the node
answers no template with `pow_epoch` within `--node-wait-secs` (default 60), and prints `node <url> answers templates: ...`
before it accepts a member; a `STATUS` line every 30 s names the member count, jobs issued, shares and the node's state.

View file

@ -13,11 +13,14 @@ pub const POOL_FIELDS: &[&str] = &[
"name", "url", "address", "algorithm", "scheme", "fee_percent", "min_payout_ign", "pplns_window_blocks", "hashrate", "hashrate_unit", "hashrate_1h",
"miners", "workers", "shares", "blocks_24h", "blocks_confirmed_24h", "blocks_orphaned_24h", "blocks_total", "blocks_confirmed_total", "blocks_orphaned_total",
"last_block", "effort_current", "luck_24h", "paid_24h_ign", "paid_total_ign", "pending_balance_ign", "pool_fee_total_ign", "uptime_s", "share_check_ms", "dry_run",
"mode", "tls", "tls_pin", "software_dev_fee_percent", "node_state",
];
pub const NETWORK_FIELDS: &[&str] = &[
"name", "chain_id", "difficulty", "hashrate", "hashrate_unit", "daa_score", "block_count", "blue_score", "block_reward_ign", "miner_reward_ign", "block_time_target_s", "synced", "node_version", "epoch_seed", "epoch_index", "updated_ms",
"finality", "finality_locked_index", "finality_locked_age_s", "payout_rule",
];
pub const NETWORK_FIELDS: &[&str] =
&["name", "chain_id", "difficulty", "hashrate", "hashrate_unit", "daa_score", "block_count", "blue_score", "block_reward_ign", "miner_reward_ign", "block_time_target_s", "synced", "node_version", "epoch_seed", "epoch_index", "updated_ms"];
pub const BLOCK_FIELDS: &[&str] = &["hash", "daa_score", "blue_score", "time", "ts_ms", "finder", "worker", "status", "reward_ign", "fee_ign", "effort", "payees", "confirmed_ms", "nonce", "shift"];
pub const MINER_FIELDS: &[&str] = &["ok", "address", "hashrate", "hashrate_1h", "hashrate_reported", "shares", "workers", "blocks", "balance_ign", "paid_ign", "payments", "last_share_ms", "first_seen_ms", "online", "proving"];
pub const MINER_FIELDS: &[&str] = &["ok", "address", "hashrate", "hashrate_1h", "hashrate_reported", "shares", "workers", "blocks", "balance_ign", "paid_ign", "payments", "last_share_ms", "first_seen_ms", "online", "proving", "history"];
pub const PAYMENT_FIELDS: &[&str] = &["time", "ts_ms", "address", "amount_ign", "tx_hash", "status", "dry_run", "nonce"];
pub const HIVE_FIELDS: &[&str] = &["hashrate", "miners", "workers", "blocks", "lastBlock", "fee", "minPayout", "scheme", "symbol", "algo", "difficulty", "networkHashrate", "height"];
@ -68,9 +71,13 @@ pub fn stats(pool: &Pool) -> Value {
let (n, mean, p50, p99, max) = s.check_cost();
let pending: u128 = s.balances.values().sum();
let paid_total: u128 = s.paid.values().sum();
// luck over 24 h: expected blocks from the weight of accepted shares against confirmed blocks found
// luck over 24 h in MiningPoolStats' convention (Q69, Q72): expected work over actual, so over 100% is lucky.
// Expected: one block of work per block found; actual: the share weight the blocks took (effort) plus the
// current round. Earlier versions reported the inverse (actual over expected).
let shares_24h_weight: f64 = s.blocks.iter().filter(|b| b.found_ms >= unix_ms().saturating_sub(86_400_000)).map(|b| b.effort).sum::<f64>() + s.pplns.since_block;
let luck = if f24 > 0 && shares_24h_weight > 0.0 { f24 as f64 / shares_24h_weight } else { 0.0 };
let template_age_s = { let t = pool.last_template_ok_ms.load(std::sync::atomic::Ordering::Relaxed); if t == 0 { u64::MAX } else { unix_ms().saturating_sub(t) / 1000 } };
let node_state = if !net.synced && net.updated_ms == 0 { "unreachable" } else if template_age_s > 30 { "no template" } else if !net.synced { "syncing" } else { "ok" };
json!({
"ok": true,
"now": iso(unix_ms()),
@ -89,12 +96,18 @@ pub fn stats(pool: &Pool) -> Value {
"uptime_s": pool.started.elapsed().as_secs(),
"share_check_ms": { "count": n, "mean": round(mean, 3), "p50": round(p50, 3), "p99": round(p99, 3), "max": round(max, 3) },
"dry_run": pool.cfg.dry_run,
"mode": if pool.open.is_some() { "open" } else { "operator" },
"tls": pool.tls.is_some(), "tls_pin": pool.tls.as_ref().map(|t| t.pin.clone()),
"software_dev_fee_percent": pool.open.as_ref().map(|o| o.dev_fee_percent as f64).unwrap_or(0.0),
"node_state": node_state,
},
"network": {
"name": net.network, "chain_id": net.chain_id, "difficulty": net.difficulty, "hashrate": net.hashrate, "hashrate_unit": "H/s",
"daa_score": net.daa_score, "block_count": net.block_count, "blue_score": net.blue_score,
"block_reward_ign": net.block_reward_ign, "miner_reward_ign": net.miner_reward_ign, "block_time_target_s": 1,
"synced": net.synced, "node_version": net.node_version, "epoch_seed": net.epoch_seed, "epoch_index": net.epoch_index, "updated_ms": net.updated_ms,
"finality": net.finality, "finality_locked_index": net.finality_locked_index, "finality_locked_age_s": net.finality_locked_age_s,
"payout_rule": "payouts follow blue confirmation, not finality",
},
"source": "igneum-pool v0: shares verified on the CPU warp verifier; network numbers from the pool's node (getBlockDagInfo, estimateNetworkHashesPerSecond); reward by spec 2.5 at the node's DAA score",
})
@ -147,9 +160,44 @@ pub fn miner(pool: &Pool, address: &str) -> Value {
"payments": s.payments.iter().rev().filter(|p| p.address == address).take(50).map(payment_json).collect::<Vec<_>>(),
"last_share_ms": rec.last_share_ms, "first_seen_ms": rec.first_seen_ms, "online": !live.is_empty(),
"proving": live.iter().any(|x| x.3),
"history": s.history_of(&address).iter().map(|(t, hs, n)| json!({"hour_ms": t, "hashrate": round(*hs, 0), "shares": n})).collect::<Vec<_>>(),
})
}
/// Q72: a Prometheus text exposition of the pool's numbers.
pub fn metrics(pool: &Pool) -> String {
let v = stats(pool);
let p = &v["pool"];
let n = &v["network"];
let g = |k: &str, val: &Value, help: &str| format!("# HELP igneum_pool_{k} {help}\n# TYPE igneum_pool_{k} gauge\nigneum_pool_{k} {}\n", val.as_f64().unwrap_or(0.0));
let mut out = String::new();
out += &g("hashrate_hs", &p["hashrate"], "pool hash rate over 10 minutes, H/s");
out += &g("miners", &p["miners"], "addresses online");
out += &g("workers", &p["workers"], "workers online");
out += &g("shares_accepted_total", &p["shares"]["accepted"], "accepted shares");
out += &g("shares_stale_total", &p["shares"]["stale"], "stale shares");
out += &g("shares_rejected_total", &p["shares"]["rejected"], "rejected shares");
out += &g("blocks_total", &p["blocks_total"], "blocks found");
out += &g("blocks_confirmed_total", &p["blocks_confirmed_total"], "blocks confirmed blue");
out += &g("blocks_orphaned_total", &p["blocks_orphaned_total"], "blocks orphaned");
out += &g("paid_total_ign", &p["paid_total_ign"], "IGN paid");
out += &g("pending_balance_ign", &p["pending_balance_ign"], "IGN owed");
out += &g("share_check_ms_p99", &p["share_check_ms"]["p99"], "share check p99, ms");
out += &g("network_hashrate_hs", &n["hashrate"], "network hash rate, H/s");
out += &g("network_daa_score", &n["daa_score"], "network DAA score");
out += &g("network_finality_locked_index", &n["finality_locked_index"], "latest locked checkpoint");
out += &format!("# HELP igneum_pool_node_ok 1 when the node is synced and answered a template in the last 30 s\n# TYPE igneum_pool_node_ok gauge\nigneum_pool_node_ok {}\n", if p["node_state"] == "ok" { 1 } else { 0 });
out += &format!("# HELP igneum_pool_finality_active 1 when the network's finality is active\n# TYPE igneum_pool_finality_active gauge\nigneum_pool_finality_active {}\n", if n["finality"] == "active" { 1 } else { 0 });
if let Some(o) = &pool.open {
let s = o.status();
out += &g("open_height", &s["height"], "share chain height");
out += &g("open_stale_total", &s["stale"], "stale shares");
out += &g("open_reorgs_total", &s["reorgs"], "share chain reorgs");
out += &g("open_rejected_total", &s["rejected"], "shares refused");
}
out
}
/// Hive-style pool stats: the flat camelCase object pool dashboards poll (approximate: Hive fixes no schema for a
/// custom pool, so this mirrors the common shape of MiningPoolStats pool JSON).
pub fn hive(pool: &Pool) -> Value {
@ -164,13 +212,23 @@ pub fn hive(pool: &Pool) -> Value {
}
fn page(pool: &Pool) -> String {
let (fee, min_payout, mode) = match &pool.open {
Some(o) => (format!("{} (software dev fee, a split entry)", o.dev_fee_percent), "none: the coinbase pays".to_string(), "open"),
None => (format!("{}", pool.cfg.fee_percent), format!("{}", pool.cfg.min_payout_ign), "operator"),
};
include_str!("../web/index.html")
.replace("__POOL_NAME__", &pool.cfg.name)
.replace("__POOL_URL__", &pool.cfg.public_url)
.replace("__POOL_ADDRESS__", &pool.pool_address_hex)
.replace("__NETWORK__", &pool.cfg.network)
.replace("__FEE__", &format!("{}", pool.cfg.fee_percent))
.replace("__MIN_PAYOUT__", &format!("{}", pool.cfg.min_payout_ign))
.replace("__FEE__", &fee)
.replace("__MIN_PAYOUT__", &min_payout)
.replace("__MODE__", mode)
.replace("__TLS__", &match &pool.tls {
Some(t) if t.self_signed => format!("--pool-pin {}", t.pin),
Some(_) => "--pool-tls".to_string(),
None => String::new(),
})
}
fn respond(status: &str, ctype: &str, body: &[u8]) -> Vec<u8> {
@ -187,23 +245,39 @@ pub fn route(pool: &Pool, path: &str) -> Vec<u8> {
let (path, query) = path.split_once('?').unwrap_or((path, ""));
let limit = query.split('&').find_map(|kv| kv.strip_prefix("limit=")).and_then(|v| v.parse::<usize>().ok()).unwrap_or(100).min(1000);
let json = |v: Value| respond("200 OK", "application/json; charset=utf-8", v.to_string().as_bytes());
let from = query.split('&').find_map(|kv| kv.strip_prefix("from=")).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
match path {
"/" | "/index.html" => respond("200 OK", "text/html; charset=utf-8", page(pool).as_bytes()),
"/api/stats" => json(stats(pool)),
"/api/blocks" => json(blocks(pool, limit)),
"/api/payments" => json(payments(pool, limit)),
"/api/pool-stats" | "/api/hive" => json(hive(pool)),
"/health" => json(json!({"ok": true})),
"/metrics" => respond("200 OK", "text/plain; version=0.0.4; charset=utf-8", metrics(pool).as_bytes()),
// Q72: health reflects the node (synced, a template in the last 30 s), not the process alone
"/health" => {
let v = stats(pool);
let ok = v["pool"]["node_state"] == "ok";
let body = json!({"ok": ok, "node_state": v["pool"]["node_state"], "synced": v["network"]["synced"], "finality": v["network"]["finality"]});
respond(if ok { "200 OK" } else { "503 Service Unavailable" }, "application/json; charset=utf-8", body.to_string().as_bytes())
}
"/api/open" => match &pool.open {
Some(o) => json(json!({"ok": true, "open": o.status()})),
None => json(json!({"ok": false, "error": "not an open pool"})),
},
"/api/open/shares" => match &pool.open {
Some(o) => json(o.shares_json(from, limit)),
None => json(json!({"ok": false, "error": "not an open pool"})),
},
p if p.starts_with("/api/open/share/") => match pool.open.as_ref().and_then(|o| o.share_json(&p["/api/open/share/".len()..])) {
Some(v) => json(json!({"ok": true, "share": v})),
None => respond("404 Not Found", "application/json; charset=utf-8", br#"{"ok":false,"error":"unknown share"}"#),
},
p if p.starts_with("/api/miners/") => json(miner(pool, &p["/api/miners/".len()..])),
_ => respond("404 Not Found", "application/json; charset=utf-8", br#"{"ok":false,"error":"not found"}"#),
}
}
pub async fn serve(pool: Arc<Pool>) {
let listener = tokio::net::TcpListener::bind(&pool.cfg.http).await.unwrap_or_else(|e| {
eprintln!("cannot listen on {}: {e}", pool.cfg.http);
std::process::exit(1)
});
pub async fn serve(pool: Arc<Pool>, listener: tokio::net::TcpListener) {
println!("{} pool: stats API and page on http://{}/", crate::state::unix_ms(), pool.cfg.http);
loop {
let Ok((sock, _)) = listener.accept().await else { continue };

View file

@ -33,16 +33,54 @@ pub struct Config {
/// Shares sampled at shifts above this are still verified in v0 (spec sample_shift 8 is an allowance, not a duty)
pub verify_threads: usize,
pub snapshot_interval_s: u64,
/// How long the start-up waits for the node to answer a template before the daemon refuses to start (exit 3)
pub node_wait_secs: u64,
/// Per-member template fetch timeout. 7 October 2026, 06:08Z: pool-1's node built a template in 1.6 to 1.8 s under
/// its own miner plus four members, the fixed 5 s gave up on the parallel fetches, no job was issued for 7 minutes,
/// every block the stale templates found was an orphan, and vardiff could not act (a new target rides with a job)
pub template_timeout_s: u64,
/// Template fetches in flight at once against the node (7 October 2026, the ten-member window on pool-1: ten
/// parallel getBlockTemplate calls on one gRPC connection against a node that builds a template in 1.6 to 1.8 s
/// queued past the client's own request timeout, "RPC request timeout" on every member template from 12:54Z for
/// 36 minutes, no job issued, no share); the rest of the members take the next tick's template
pub template_parallel: usize,
/// TLS 1.3 on the member port (spec 9.3, `tls.rs`): a PEM chain and key, or a self-signed pair under the data dir
pub tls_cert: Option<PathBuf>,
pub tls_key: Option<PathBuf>,
pub tls_self_signed: bool,
/// Plain TCP members on testnet or mainnet (refused without it; the devnet allows plain by default)
pub allow_plain: bool,
/// Q72: an opt-in webhook POSTed when a known address sends no share for 10 minutes
pub alert_webhook: Option<String>,
/// The open pool (docs/plans/pool.md section 10): a share sidechain with no operator, every member on its own node
pub open: bool,
/// Open pool: the share chain's P2P listener and the peers to connect to
pub p2p_listen: String,
pub peers: Vec<String>,
/// Open pool: the share chain's target seconds per share (its difficulty rule keeps it), the PPLNS window in
/// shares, the software dev fee as a split entry (percent), and the chain name every member must share
pub chain_share_s: f64,
pub window_shares: usize,
pub open_dev_fee_percent: u32,
pub open_chain: String,
/// Open pool: the share chain's first target (16 hex), a chain constant every member shares; the default is the
/// network's genesis block target eight times easier (`open.rs`)
pub chain_genesis_target64: Option<u64>,
/// Open pool test flag: keep this daemon's own shares to itself (the withheld-share case of the gate)
pub withhold_shares: bool,
}
impl Config {
pub fn usage() -> ! {
eprintln!(
"usage: igneum-pool [--node grpc://127.0.0.1:26610]... [--evm-rpc http://127.0.0.1:26790] [--listen 0.0.0.0:4463]\n\
"usage: igneum-pool [--node grpc://127.0.0.1:26610]... [--evm-rpc http://127.0.0.1:26790] [--listen 0.0.0.0:4463] [--node-wait-secs 60] [--template-timeout-s 20] [--template-parallel 2]\n\
\x20 [--http 127.0.0.1:4480] [--data-dir ./data] [--payout-key <file>] [--fee-percent 1] [--min-payout 1.0]\n\
\x20 [--pplns-window 2.0] [--payout-interval-s 60] [--dry-run] [--network devnet|testnet|mainnet]\n\
\x20 [--share-interval-s 10] [--min-shift 0] [--max-shift 60] [--stale-grace-ms 2000] [--orphan-after-daa 120]\n\
\x20 [--name \"Igneum pool\"] [--public-url host:4463] [--verify-threads 2] [--snapshot-interval-s 15]"
\x20 [--name \"Igneum pool\"] [--public-url host:4463] [--verify-threads 2] [--snapshot-interval-s 15]\n\
\x20 [--tls-cert <pem> --tls-key <pem> | --tls-self-signed] [--allow-plain] [--alert-webhook <url>]\n\
\x20 [--open [--p2p-listen 0.0.0.0:4473] [--peer host:port]... [--chain-share-s 10] [--window-shares 2160]\n\
\x20 [--open-dev-fee-percent 1] [--open-chain igneum-open-v1] [--chain-genesis-target64 <hex>]]"
);
std::process::exit(2)
}
@ -70,6 +108,23 @@ impl Config {
public_url: String::new(),
verify_threads: 2,
snapshot_interval_s: 15,
node_wait_secs: 60,
template_timeout_s: 20,
template_parallel: 2,
tls_cert: None,
tls_key: None,
tls_self_signed: false,
allow_plain: false,
alert_webhook: None,
open: false,
p2p_listen: String::new(),
peers: Vec::new(),
chain_share_s: 10.0,
window_shares: 2160,
open_dev_fee_percent: 1,
open_chain: "igneum-open-v1".into(),
chain_genesis_target64: None,
withhold_shares: false,
};
let mut i = 0;
while i < args.len() {
@ -100,6 +155,23 @@ impl Config {
"--public-url" => c.public_url = val(),
"--verify-threads" => c.verify_threads = val().parse::<usize>().unwrap_or_else(|_| Config::usage()).max(1),
"--snapshot-interval-s" => c.snapshot_interval_s = val().parse().unwrap_or_else(|_| Config::usage()),
"--node-wait-secs" => c.node_wait_secs = val().parse().unwrap_or_else(|_| Config::usage()),
"--template-timeout-s" => c.template_timeout_s = val().parse::<u64>().unwrap_or_else(|_| Config::usage()).max(1),
"--template-parallel" => c.template_parallel = val().parse::<usize>().unwrap_or_else(|_| Config::usage()).max(1),
"--tls-cert" => c.tls_cert = Some(PathBuf::from(val())),
"--tls-key" => c.tls_key = Some(PathBuf::from(val())),
"--tls-self-signed" => c.tls_self_signed = true,
"--allow-plain" => c.allow_plain = true,
"--alert-webhook" => c.alert_webhook = Some(val()),
"--open" => c.open = true,
"--p2p-listen" => c.p2p_listen = val(),
"--peer" => c.peers.push(val()),
"--chain-share-s" => c.chain_share_s = val().parse::<f64>().ok().filter(|v| *v > 0.0).unwrap_or_else(|| Config::usage()),
"--window-shares" => c.window_shares = val().parse::<usize>().ok().filter(|v| *v > 0).unwrap_or_else(|| Config::usage()),
"--open-dev-fee-percent" => c.open_dev_fee_percent = val().parse::<u32>().ok().filter(|v| *v <= 50).unwrap_or_else(|| Config::usage()),
"--open-chain" => c.open_chain = val(),
"--chain-genesis-target64" => c.chain_genesis_target64 = Some(u64::from_str_radix(val().trim_start_matches("0x"), 16).unwrap_or_else(|_| Config::usage())),
"--withhold-shares" => c.withhold_shares = true,
"-h" | "--help" => Config::usage(),
_ => Config::usage(),
}
@ -128,9 +200,20 @@ impl Config {
if c.fee_percent < 0.0 || c.fee_percent > 100.0 || c.pplns_window_blocks <= 0.0 || c.min_shift > c.max_shift {
Config::usage();
}
if c.tls_cert.is_some() != c.tls_key.is_some() || (c.tls_self_signed && c.tls_cert.is_some()) {
eprintln!("--tls-cert and --tls-key go together; --tls-self-signed stands alone");
Config::usage();
}
if c.p2p_listen.is_empty() {
c.p2p_listen = format!("0.0.0.0:{}", port + 10);
}
c
}
pub fn tls_on(&self) -> bool {
self.tls_cert.is_some() || self.tls_self_signed
}
pub fn chain_id(&self) -> u64 {
match self.network.as_str() {
"mainnet" => 4461,

View file

@ -14,12 +14,16 @@
mod api;
mod config;
mod node;
mod open;
mod p2p;
mod payout;
mod pool;
mod pplns;
mod protocol;
mod server;
mod sidechain;
mod state;
mod tls;
mod vardiff;
mod verify;
@ -27,6 +31,7 @@ use config::Config;
use kaspa_addresses::{Address, Prefix, Version};
use kaspa_grpc_client::GrpcClient;
use kaspa_pow::igneum::IgneumEngine;
use kaspa_rpc_core::api::rpc::RpcApi;
use pool::{NetInfo, Pool};
use state::{State, WEI_PER_IGN};
use std::collections::HashMap;
@ -37,17 +42,53 @@ use std::time::{Duration, Instant};
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
// `igneum-pool verify-share <json line> [--block <raw block json>]`: the drop proof from a member's own log
if args.first().map(|a| a.as_str()) == Some("verify-share") {
std::process::exit(sidechain::verify_share_cli(&args[1..]));
}
let cfg = Config::from_args(&args);
// spec 9.3: TLS 1.3 on the member port; the public networks refuse the clear unless told
let tls = if cfg.tls_on() {
match tls::load(cfg.tls_cert.as_deref(), cfg.tls_key.as_deref(), cfg.tls_self_signed.then_some(cfg.data_dir.as_path()), &cfg.name) {
Ok(t) => Some(t),
Err(e) => {
eprintln!("POOL NOT STARTED: TLS: {e}");
std::process::exit(2)
}
}
} else {
if cfg.network != "devnet" && !cfg.allow_plain {
eprintln!("POOL NOT STARTED: --network {} wants TLS 1.3 on the member port (spec 9.3): --tls-self-signed or --tls-cert/--tls-key, or --allow-plain to say otherwise", cfg.network);
std::process::exit(2)
}
None
};
// The two listeners first (7 October 2026, the fleet agent's row from the 6 October night: a daemon whose member
// port another process still held ran 20 minutes as a process with no socket): a port that cannot be bound ends
// the start here, exit code 2, before a node is contacted or a member is let down.
let member_listener = server::bind_listener("members", &cfg.listen).await.unwrap_or_else(|e| {
eprintln!("POOL NOT STARTED: {e}");
std::process::exit(2)
});
let api_listener = server::bind_listener("stats API", &cfg.http).await.unwrap_or_else(|e| {
eprintln!("POOL NOT STARTED: {e}");
std::process::exit(2)
});
std::fs::create_dir_all(&cfg.data_dir).unwrap_or_else(|e| {
eprintln!("data dir {}: {e}", cfg.data_dir.display());
std::process::exit(1)
});
let signer = payout::load_or_create_key(&cfg.payout_key).unwrap_or_else(|e| {
eprintln!("payout key: {e}");
std::process::exit(1)
});
let pool_address: [u8; 20] = signer.address().into_array();
let pool_address_hex = format!("0x{}", hex::encode(pool_address));
// the open pool holds no key: no payout key is made or read, the members' own addresses are in every coinbase
let signer = if cfg.open {
None
} else {
Some(payout::load_or_create_key(&cfg.payout_key).unwrap_or_else(|e| {
eprintln!("payout key: {e}");
std::process::exit(1)
}))
};
let pool_address: [u8; 20] = signer.as_ref().map(|s| s.address().into_array()).unwrap_or([0u8; 20]);
let pool_address_hex = if cfg.open { "none (open pool: every coinbase pays the window)".to_string() } else { format!("0x{}", hex::encode(pool_address)) };
let evm = payout::EvmRpc::new(&cfg.evm_rpc).unwrap_or_else(|e| {
eprintln!("--evm-rpc: {e}");
std::process::exit(1)
@ -68,6 +109,44 @@ async fn main() {
}
}
let node = clients.remove(0);
let walker = match tokio::time::timeout(Duration::from_secs(10), GrpcClient::connect(cfg.nodes[0].clone())).await {
Ok(Ok(c)) => Arc::new(c),
Ok(Err(e)) => {
eprintln!("node {} (second connection): {e}", cfg.nodes[0]);
std::process::exit(1)
}
Err(_) => {
eprintln!("node {} (second connection): connect timed out", cfg.nodes[0]);
std::process::exit(1)
}
};
// The node must answer a template with its pow_epoch before the pool serves anyone: a pool whose node answers no
// template issues no job (the 6 October night: four members connected, "template timed out" per member, no job
// for 20 minutes). Retried for --node-wait-secs, saying so; then exit code 3.
{
let probe_address = Address::new(Prefix::Devnet, Version::PubKey, &[0u8; 32]);
let deadline = Instant::now() + Duration::from_secs(cfg.node_wait_secs);
let mut attempt = 0u32;
loop {
attempt += 1;
let why = match tokio::time::timeout(Duration::from_secs(10), node.get_block_template(probe_address.clone(), Vec::new())).await {
Ok(Ok(t)) if t.pow_epoch.is_some() => {
let i = t.pow_epoch.unwrap();
println!("{} node {} answers templates: epoch {} class {} era {} daa {}", state::unix_ms(), cfg.nodes[0], i.epoch_index, i.class().name(), i.era_seed.map(|h| h.to_string()).unwrap_or_else(|| "-".into()), i.virtual_daa_score);
break;
}
Ok(Ok(_)) => "the template carries no pow_epoch (a node before the 0.3.x line); the pool cannot name a program".to_string(),
Ok(Err(e)) => e.to_string(),
Err(_) => "template timed out after 10 s".to_string(),
};
if Instant::now() >= deadline {
eprintln!("POOL NOT STARTED: node {} answered no usable template in {} s ({attempt} attempts; last: {why})", cfg.nodes[0], cfg.node_wait_secs);
std::process::exit(3)
}
eprintln!("{} waiting for node {} to answer a template (attempt {attempt}: {why}); giving up at {} s", state::unix_ms(), cfg.nodes[0], cfg.node_wait_secs);
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
// the UTXO-side coinbase address: derived from the pool's EVM address; the execution layer pays the EVM side
// (igneum/exec/src/executor.rs), the UTXO output is not spent by v0 (docs/plans/pool.md)
let prefix = match cfg.network.as_str() {
@ -82,14 +161,17 @@ async fn main() {
Address::new(prefix, Version::PubKey, &h.finalize().as_bytes())
};
let state_path = cfg.data_dir.join("state.json");
let engine = Arc::new(IgneumEngine::new());
let walker_for_open = walker.clone();
let pool = Arc::new(Pool {
engine: Arc::new(IgneumEngine::new()),
engine: engine.clone(),
state: Mutex::new(State::load(&state_path, cfg.pplns_window_blocks)),
members: Mutex::new(HashMap::new()),
next_member_id: AtomicU64::new(0),
next_template_id: AtomicU64::new(0),
next_job_id: AtomicU64::new(0),
node,
walker,
extra_nodes: clients,
pool_address,
pool_address_hex: pool_address_hex.clone(),
@ -97,8 +179,13 @@ async fn main() {
net: Mutex::new(NetInfo::default()),
want_templates: tokio::sync::Notify::new(),
verify_permits: tokio::sync::Semaphore::new(cfg.verify_threads),
template_permits: tokio::sync::Semaphore::new(cfg.template_parallel),
started: Instant::now(),
cfg: cfg.clone(),
template_failures: AtomicU64::new(0),
last_template_ok_ms: AtomicU64::new(0),
tls,
open: cfg.open.then(|| Arc::new(open::Open::new(&cfg, &state_path, engine.clone(), walker_for_open))),
});
let chain = evm.call("eth_chainId", serde_json::json!([])).await.ok().and_then(|v| v.as_str().map(|s| s.to_string()));
println!(
@ -113,16 +200,32 @@ async fn main() {
cfg.pplns_window_blocks,
if cfg.dry_run { ", DRY RUN (payouts computed, not sent)" } else { "" }
);
let payer = Arc::new(payout::Payer { signer, rpc: evm, chain_id: cfg.chain_id(), dry_run: cfg.dry_run, min_payout_wei: (cfg.min_payout_ign * WEI_PER_IGN as f64) as u128 });
if let Some(o) = &pool.open {
println!(
"{} OPEN POOL: share chain '{}' ({} s per share, window {} shares, software dev fee {}% as a split entry), p2p on {}, {} peer(s); no operator key, no balance: every coinbase pays the window from DAA {} (pool_split_activation_daa on the node)",
state::unix_ms(),
cfg.open_chain,
cfg.chain_share_s,
cfg.window_shares,
o.dev_fee_percent,
cfg.p2p_listen,
cfg.peers.len(),
o.activation_note()
);
tokio::spawn(p2p::run(pool.clone(), o.clone()));
}
let payer = signer.map(|signer| Arc::new(payout::Payer { signer, rpc: evm, chain_id: cfg.chain_id(), dry_run: cfg.dry_run, min_payout_wei: (cfg.min_payout_ign * WEI_PER_IGN as f64) as u128 }));
tokio::spawn(node::template_feed(pool.clone()));
tokio::spawn(state::alert_loop(pool.clone()));
tokio::spawn(node::confirm_loop(pool.clone()));
tokio::spawn(node::net_loop(pool.clone()));
tokio::spawn(node::vardiff_loop(pool.clone()));
tokio::spawn(server::listen(pool.clone()));
tokio::spawn(api::serve(pool.clone()));
{
let (pool, payer) = (pool.clone(), payer.clone());
tokio::spawn(node::status_loop(pool.clone()));
tokio::spawn(server::listen(pool.clone(), member_listener));
tokio::spawn(api::serve(pool.clone(), api_listener));
if let Some(payer) = payer {
let pool = pool.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(pool.cfg.payout_interval_s.max(5))).await;

View file

@ -35,19 +35,85 @@ pub fn extra_data(member: &Member, pool_address: &[u8; 20]) -> Vec<u8> {
v
}
/// One template for one member, turned into a job. Blocks the thread on the first use of a seed pair (the 256 MiB
/// cache build), so it runs under `spawn_blocking`.
/// Rewrites a template's coinbase extra data in place and re-derives the header's merkle root exactly as the node's
/// body check does (`calc_block_hash_merkle_root` over the UTXO transactions and the EVM transactions, design D7),
/// so the block the member hashes is the block the node accepts. The open pool restamps every template with the
/// share chain's parent and the window's split (`open.rs`); the node is asked for a template once a second, not
/// once per share-chain tip.
pub fn restamp_extra_data(raw: &mut RpcRawBlock, extra: &[u8]) -> Result<(), String> {
let cb = raw.transactions.first_mut().ok_or("template without a coinbase")?;
let keep = {
let p = &cb.payload;
if p.len() < 19 {
return Err("coinbase payload too short".into());
}
19 + p[18] as usize
};
if cb.payload.len() < keep {
return Err("coinbase payload shorter than its script".into());
}
cb.payload.truncate(keep);
cb.payload.extend_from_slice(extra);
let block: Block = raw.clone().try_into().map_err(|e| format!("block convert: {e}"))?;
raw.header.hash_merkle_root = kaspa_consensus_core::merkle::calc_block_hash_merkle_root(block.transactions.iter(), block.evm_transactions.iter());
Ok(())
}
/// One template for one member from the node, turned into a job (`issue_job`). The template is kept per member so
/// the open pool can re-stamp it on a new share-chain parent without asking the node again (`reissue_job`).
pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), String> {
let t0 = Instant::now();
let tmpl = tokio::time::timeout(Duration::from_secs(5), pool.node.get_block_template(pool.pay_address.clone(), extra_data(member, &pool.pool_address)))
.await
.map_err(|_| "template timed out".to_string())?
.map_err(|e| e.to_string())?;
let mut raw = tmpl.block;
// the open pool: the member's own address in the coinbase (the window's split is stamped below), never the pool's
let base_extra = match &pool.open {
Some(o) => o.base_extra_data(member),
None => extra_data(member, &pool.pool_address),
};
// at most `--template-parallel` fetches in flight: the gRPC client is one request stream per connection and a
// node under load builds a template in over a second, so a burst of one fetch per member queued past the client's
// request timeout (pool-1, 7 October 2026, 12:54Z to 13:30Z: 1,891 "RPC request timeout" lines, no job issued)
let _permit = pool.template_permits.acquire().await.map_err(|e| e.to_string())?;
let tmpl = match tokio::time::timeout(Duration::from_secs(pool.cfg.template_timeout_s), pool.node.get_block_template(pool.pay_address.clone(), base_extra)).await {
Ok(Ok(t)) => t,
Ok(Err(e)) => {
pool.template_failures.fetch_add(1, Ordering::Relaxed);
return Err(e.to_string());
}
Err(_) => {
pool.template_failures.fetch_add(1, Ordering::Relaxed);
return Err(format!("template timed out after {} s (--template-timeout-s)", pool.cfg.template_timeout_s));
}
};
pool.last_template_ok_ms.store(unix_ms(), Ordering::Relaxed);
let info = *tmpl.pow_epoch.as_ref().ok_or("the node reports no pow_epoch; a devnet-v4 line node is needed")?;
member.inner.lock().unwrap().last_template = Some((tmpl.block.clone(), info));
let r = issue_job(pool, member, tmpl.block, &info).await;
let _ = t0;
r
}
/// The open pool: the member's last template re-stamped on the current share-chain parent (no node call).
pub async fn reissue_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), String> {
let (raw, info) = member.inner.lock().unwrap().last_template.clone().ok_or("no template yet")?;
issue_job(pool, member, raw, &info).await
}
/// A job on a template: the member's key in the header, the open pool's stamp, the seeds, the share target, the
/// `seeds`, `template` and `job` lines. Blocks the thread on the first use of a seed pair (the cache build), under
/// `spawn_blocking`.
async fn issue_job(pool: &Arc<Pool>, member: &Arc<Member>, mut raw: RpcRawBlock, info: &kaspa_rpc_core::RpcPowEpochInfo) -> Result<(), String> {
raw.header.vote_key_hash = member.key_hash;
// the open pool: the share chain's parent and the window's split ride in the coinbase; the share target is the
// chain's (the member's vardiff target never goes below it)
let (open_parent, open_share_target64) = match &pool.open {
Some(o) => {
let (parent, target, extra) = o.stamp_for(member, info, &raw)?;
restamp_extra_data(&mut raw, &extra)?;
(Some(parent), target)
}
None => (None, 0),
};
let block: Block = raw.clone().try_into().map_err(|e| format!("block convert: {e}"))?;
let header: Header = block.header.as_ref().clone();
let info = tmpl.pow_epoch.as_ref().ok_or("the node reports no pow_epoch; a devnet-v4 line node is needed")?;
// The node's PoW schedule, genesis day and dataset size, and class v3 activation are the pool's (what the solo
// miner's `template()` installs): the share verifier's day cache and program must be the node's exactly
let wanted = PowSchedule::clamped(info.epoch_blocks, info.epoch_lead, info.day_ms);
@ -66,7 +132,10 @@ pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), Str
// Counter ASIC 2.0: the class and the era seed of the epoch ride with the template; the verifier hashes the
// program they name, the same one the members' workers compile (6 October 2026: a fixed class here or on the
// member refused every GPU share of the fleet run)
let seeds = EpochSeeds { epoch: info.epoch_seed, day: day_index(header.timestamp), class: info.class(), era: info.era_seed.unwrap_or(kaspa_hashes::ZERO_HASH) };
let seeds = EpochSeeds { epoch: info.epoch_seed, day: day_index(header.timestamp), class: info.class(), era: info.era_seed.unwrap_or(kaspa_hashes::ZERO_HASH), shadow_reps: info.latency_ladder_reps as u16 };
if let Some(o) = &pool.open {
o.note_epoch(info, &seeds);
}
let engine = pool.engine.clone();
let epoch = tokio::task::spawn_blocking(move || engine.epoch_for(&seeds)).await.map_err(|e| e.to_string())?;
let prehash = header_prehash(&header);
@ -89,7 +158,8 @@ pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), Str
g.vardiff.shift = cap;
}
let shift = g.vardiff.shift;
let clean = g.last_parents.as_ref() != Some(&parents);
// the open pool: a new share-chain parent supersedes the earlier jobs as a new tip does
let clean = g.last_parents.as_ref() != Some(&parents) || g.jobs.back().is_some_and(|j| j.open_parent != open_parent);
if clean {
for j in g.jobs.iter_mut() {
if j.superseded_at.is_none() {
@ -100,7 +170,10 @@ pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), Str
g.last_parents = Some(parents);
let seeds_changed = g.last_seeds != Some(seeds);
g.last_seeds = Some(seeds);
let share_t = share_target(t64, shift);
// the open pool: the member never sends shares above the chain's target (a chain share is what pays);
// a member whose vardiff target is easier than the chain's still sends its own shares for the hashrate
// figure and the chain shares among them
let share_t = share_target(t64, shift).max(open_share_target64);
g.jobs.push_back(JobRec {
job_id,
template_id,
@ -113,6 +186,8 @@ pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), Str
seen: HashSet::new(),
daa_score: header.daa_score,
blue_score: header.blue_score,
open_parent,
open_share_target64,
});
while g.jobs.len() > 12 {
g.jobs.pop_front();
@ -139,6 +214,8 @@ pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), Str
genesis_day_index: info.genesis_day_index,
genesis_dataset_log2: info.genesis_dataset_log2,
program_class_v3_activation_daa: info.program_class_v3_activation_daa,
shadow_reps: info.latency_ladder_reps,
next_shadow_reps: info.next_latency_ladder_reps,
}
.line(),
);
@ -158,11 +235,11 @@ pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), Str
day: seeds.day,
program_class: seeds.class.generator_version(),
era_seed: (seeds.class != kaspa_consensus_core::igneum::ProgramClass::V2).then(|| seeds.era.to_string()),
shadow_reps: info.latency_ladder_reps,
clean,
}
.line(),
);
let _ = t0;
Ok(())
}
@ -196,11 +273,20 @@ pub async fn template_feed(pool: Arc<Pool>) {
}
}
let mut lost = false;
// the open pool: a new share-chain tip re-stamps every member's last template without a node call
let mut restamp_only = false;
let tip_moved = async {
match &pool.open {
Some(o) => o.tip_changed.notified().await,
None => std::future::pending::<()>().await,
}
};
match &sub {
Some((_, rx)) => {
tokio::select! {
n = rx.recv() => { if n.is_err() { lost = true; } },
_ = pool.want_templates.notified() => {},
_ = tip_moved => { restamp_only = true; },
_ = tokio::time::sleep(Duration::from_secs(1)) => {},
}
if let Some((_, rx)) = &sub {
@ -208,7 +294,10 @@ pub async fn template_feed(pool: Arc<Pool>) {
}
}
None => {
let _ = tokio::time::timeout(Duration::from_secs(1), pool.want_templates.notified()).await;
tokio::select! {
_ = tokio::time::timeout(Duration::from_secs(1), pool.want_templates.notified()) => {},
_ = tip_moved => { restamp_only = true; },
}
}
}
if lost {
@ -216,6 +305,23 @@ pub async fn template_feed(pool: Arc<Pool>) {
next_sub = Instant::now() + Duration::from_secs(2);
}
let members = pool.members_snapshot();
if restamp_only {
let mut handles = Vec::with_capacity(members.len());
for m in members {
let pool = pool.clone();
handles.push(tokio::spawn(async move {
if let Err(e) = reissue_job(&pool, &m).await
&& e != "no template yet"
{
eprintln!("{} re-stamp for member {} ({}): {e}", now(), m.id, m.worker);
}
}));
}
for h in handles {
let _ = h.await;
}
continue;
}
let mut handles = Vec::with_capacity(members.len());
for m in members {
let pool = pool.clone();
@ -251,6 +357,9 @@ pub async fn submit_block(pool: Arc<Pool>, member: Arc<Member>, mut raw: RpcRawB
let reward_wei = producer_share(subsidy) as u128 * WEI_PER_SOMPI;
if accepted {
let mut s = pool.state.lock().unwrap();
if let Some(o) = &pool.open {
o.note_block(&raw, &hash, daa_score, blue_score, reward_wei, &member.address, &mut s);
} else {
s.block_found(BlockRec {
hash: hash.clone(),
daa_score,
@ -267,6 +376,7 @@ pub async fn submit_block(pool: Arc<Pool>, member: Arc<Member>, mut raw: RpcRawB
nonce: hex_u64(nonce),
shift,
});
}
member.inner.lock().unwrap().blocks += 1;
println!("{} BLOCK {} daa={} by {} ({}) nonce={:#x} reward={} IGN expected, pending", now(), &hash[..16.min(hash.len())], daa_score, member.address, member.worker, nonce, crate::state::ign(reward_wei));
} else {
@ -276,9 +386,22 @@ pub async fn submit_block(pool: Arc<Pool>, member: Arc<Member>, mut raw: RpcRawB
pool.want_templates.notify_one();
}
/// Where the confirmation walk continues from after a failed `getVirtualChainFromBlock`: the same cursor, retried
/// next tick. The first version restarted from the pruning point, and on a 120,000-block devnet chain that is a
/// `get_block` per chain block through the connection the templates shared: one RPC timeout under load (7 October 2026,
/// 06:01Z) started a walk that starved every template request for the rest of the window. A cursor the node no
/// longer knows (pruned, or from a state file of another chain) moves to the sink, and the pending blocks older than
/// it resolve by the orphan rule.
pub fn cursor_after_failure(cursor: Hash, sink: Hash, cursor_known: bool) -> Hash {
if cursor_known { cursor } else { sink }
}
/// At most this many chain blocks (and `get_block` calls) per 5 s tick; the rest continue next tick.
pub const WALK_MAX_PER_TICK: usize = 600;
/// Confirms pending blocks by walking the selected chain: a block is paid when it is a chain block or in the
/// mergeset blues of one (the execution layer pays exactly those, executor.rs `b.is_blue`); a pending block more
/// than `orphan_after_daa` behind the virtual with no blue merge is an orphan.
/// than `orphan_after_daa` behind the virtual with no blue merge is an orphan. On its own connection (`pool.walker`).
pub async fn confirm_loop(pool: Arc<Pool>) {
let mut last_chain: Option<Hash> = None;
loop {
@ -287,45 +410,69 @@ pub async fn confirm_loop(pool: Arc<Pool>) {
let s = pool.state.lock().unwrap();
s.blocks.iter().filter(|b| b.status == "pending").map(|b| (b.hash.clone(), b.daa_score)).collect()
};
let info = match pool.node.get_block_dag_info().await {
let info = match pool.walker.get_block_dag_info().await {
Ok(i) => i,
Err(_) => continue,
Err(e) => {
eprintln!("{} confirm: getBlockDagInfo failed ({e})", now());
continue;
}
};
if last_chain.is_none() {
last_chain = Some(info.pruning_point_hash);
}
if pending.is_empty() {
// keep the cursor near the tip so the first pending block costs one short walk
last_chain = Some(info.sink);
continue;
}
let pending_set: HashSet<String> = pending.iter().map(|p| p.0.clone()).collect();
let low = last_chain.unwrap();
let chain = match pool.node.get_virtual_chain_from_block(low, false, None).await {
// a first tick with pending blocks (a state file kept across a restart): from the sink, never the pruning
// point; blocks older than the sink that were blue are a payout lost to the restart, said once
let low = match last_chain {
Some(h) => h,
None => {
println!("{} confirm: {} pending block(s) at start; the walk begins at the sink, older ones resolve by the orphan rule", now(), pending.len());
last_chain = Some(info.sink);
info.sink
}
};
let t0 = Instant::now();
let chain = match pool.walker.get_virtual_chain_from_block(low, false, None).await {
Ok(c) => c,
Err(e) => {
eprintln!("{} confirm: getVirtualChainFromBlock failed ({e}); restarting from the pruning point", now());
last_chain = Some(info.pruning_point_hash);
let known = pool.walker.get_block(low, false).await.is_ok();
let next = cursor_after_failure(low, info.sink, known);
eprintln!("{} confirm: getVirtualChainFromBlock from {} failed ({e}); cursor {}", now(), low, if next == low { "kept, retried next tick".to_string() } else { format!("unknown to the node, moved to the sink {next}") });
last_chain = Some(next);
continue;
}
};
let added = &chain.added_chain_block_hashes;
let take = added.len().min(WALK_MAX_PER_TICK);
let mut blues: HashSet<String> = HashSet::new();
for h in &chain.added_chain_block_hashes {
let mut fetched = 0usize;
for h in &added[..take] {
blues.insert(h.to_string());
if let Ok(b) = pool.node.get_block(*h, false).await
&& let Some(v) = b.verbose_data
{
for m in v.merge_set_blues_hashes {
blues.insert(m.to_string());
match pool.walker.get_block(*h, false).await {
Ok(b) => {
fetched += 1;
if let Some(v) = b.verbose_data {
for m in v.merge_set_blues_hashes {
blues.insert(m.to_string());
}
}
}
Err(e) => {
eprintln!("{} confirm: getBlock {h} failed ({e}); the walk stops here and continues next tick", now());
break;
}
}
}
if let Some(h) = chain.added_chain_block_hashes.last() {
last_chain = Some(*h);
}
if added.len() > WALK_MAX_PER_TICK || t0.elapsed() > Duration::from_secs(2) {
println!("{} confirm: walked {fetched} of {} chain blocks in {:.0} ms ({} pending)", now(), added.len(), t0.elapsed().as_secs_f64() * 1e3, pending.len());
}
for (hash, daa) in pending {
if blues.contains(&hash) {
let r = pool.state.lock().unwrap().confirm_block(&hash, pool.cfg.fee_percent);
// the open pool: the coinbase paid the split; the ledger records it, no balance moves (open.rs)
let fee = if pool.open.is_some() { 0.0 } else { pool.cfg.fee_percent };
let r = pool.state.lock().unwrap().confirm_block(&hash, fee, pool.open.is_some());
if let Some((reward, parts)) = r {
println!(
"{} CONFIRMED {} reward {} IGN paid to {} addresses: {}",
@ -340,28 +487,59 @@ pub async fn confirm_loop(pool: Arc<Pool>) {
println!("{} ORPHAN {} (daa {}, virtual {}): not blue within {} DAA", now(), &hash[..16], daa, info.virtual_daa_score, pool.cfg.orphan_after_daa);
}
}
let _ = pending_set;
}
}
/// The daemon's own status line every 30 s (7 October 2026: a night where four members sat connected with no job for
/// 20 minutes was visible only as per-member "template timed out" lines).
pub async fn status_loop(pool: Arc<Pool>) {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let members = pool.members_snapshot().len();
let failures = pool.template_failures.load(Ordering::Relaxed);
let last_ok = pool.last_template_ok_ms.load(Ordering::Relaxed);
let jobs = pool.next_job_id.load(Ordering::Relaxed);
let (accepted, rejected, blocks) = {
let s = pool.state.lock().unwrap();
(s.accepted, s.rejected, s.blocks_found)
};
let node_state = if last_ok == 0 {
"NO TEMPLATE YET from the node: no job has been issued".to_string()
} else {
let ago = unix_ms().saturating_sub(last_ok) / 1000;
if ago > 30 { format!("NODE NOT ANSWERING: last template {ago} s ago") } else { format!("node ok, last template {ago} s ago") }
};
println!("{} STATUS members={members} jobs_issued={jobs} shares_accepted={accepted} shares_rejected={rejected} blocks={blocks} template_failures={failures}; {node_state}", now());
}
}
/// Network numbers every 5 s.
pub async fn net_loop(pool: Arc<Pool>) {
loop {
let mut n = NetInfo { network: pool.cfg.network.clone(), chain_id: pool.cfg.chain_id(), ..Default::default() };
if let Ok(i) = pool.node.get_block_dag_info().await {
let mut n = NetInfo { network: pool.cfg.network.clone(), chain_id: pool.cfg.chain_id(), finality: "unknown".into(), ..Default::default() };
if let Ok(i) = pool.walker.get_block_dag_info().await {
n.difficulty = i.difficulty;
n.daa_score = i.virtual_daa_score;
n.block_count = i.block_count;
n.network = i.network.to_string();
}
n.hashrate = pool.node.estimate_network_hashes_per_second(1000, None).await.ok().map(|h| h as f64);
if let Ok(b) = pool.node.get_sink_blue_score().await {
n.hashrate = pool.walker.estimate_network_hashes_per_second(1000, None).await.ok().map(|h| h as f64);
if let Ok(b) = pool.walker.get_sink_blue_score().await {
n.blue_score = b;
}
if let Ok(i) = pool.node.get_info().await {
if let Ok(i) = pool.walker.get_info().await {
n.synced = i.is_synced;
n.node_version = i.server_version;
}
// Q73: the network's finality state, for the page; payouts follow blue confirmation, not finality
match pool.walker.get_finality_checkpoints(1).await {
Ok(f) => {
n.finality = if f.finality_active { "active".to_string() } else { f.finality_reason.clone() };
n.finality_locked_index = f.latest_locked_index;
n.finality_locked_age_s = f.checkpoints.iter().find(|c| c.index == f.latest_locked_index).map(|c| n.daa_score.saturating_sub(c.locked_at_daa)).unwrap_or(0);
}
Err(_) => n.finality = "unknown".into(),
}
let subsidy = block_subsidy(n.daa_score, 1);
n.block_reward_ign = subsidy as f64 / 1e8;
n.miner_reward_ign = producer_share(subsidy) as f64 / 1e8;
@ -407,3 +585,21 @@ pub async fn vardiff_loop(pool: Arc<Pool>) {
let _ = pool.next_member_id.load(Ordering::Relaxed);
}
}
#[cfg(test)]
mod walk_tests {
use super::*;
/// The cursor rule after a failed chain call: known cursor kept (the 6 October shape restarted from the pruning
/// point and walked 120,000 blocks), unknown cursor moved to the sink.
#[test]
fn a_failed_chain_call_keeps_a_known_cursor_and_never_goes_to_the_pruning_point() {
let cursor = Hash::from_bytes([1u8; 32]);
let sink = Hash::from_bytes([2u8; 32]);
let pruning = Hash::from_bytes([3u8; 32]);
assert_eq!(cursor_after_failure(cursor, sink, true), cursor);
assert_eq!(cursor_after_failure(cursor, sink, false), sink);
assert_ne!(cursor_after_failure(cursor, sink, false), pruning);
assert!(WALK_MAX_PER_TICK <= 1000, "a tick's walk stays bounded");
}
}

422
pool/src/open.rs Normal file
View file

@ -0,0 +1,422 @@
//! The open pool: `igneum-pool --open`, the share sidechain with no operator (docs/plans/pool.md section 10; spec 09
//! section 9.12; mission item 11, 7 October 2026).
//!
//! The daemon is the member's own: it runs beside the member's node, serves the spec 09 member protocol on loopback
//! (or on a rig's LAN) to that member's miners, builds every template from the member's node with the member's key
//! in the header and the member's own address in the coinbase, stamps the share chain's parent and the window's
//! split into the coinbase, and keeps the share chain with its peers over `p2p.rs`. There is no payout key, no
//! balance and no fee to an operator: the block's own coinbase pays the window (`sidechain.rs`); the only fee is the
//! software dev fee as a split entry, the same 1 percent the solo miner and pool-0 carry, so the three are
//! fee-neutral and the choice is about variance alone (reinvent 3.5).
use crate::pool::{Member, Pool};
use crate::sidechain::{check_pow, ChainParams, SeedsWire, Share, ShareChain, GENESIS_PARENT};
use crate::state::{unix_ms, BlockRec, State};
use crate::verify::JobKey;
use kaspa_consensus_core::block::Block;
use kaspa_consensus_core::evm::payout_split_in;
use kaspa_consensus_core::igneum::{pow_epoch_blocks, pow_schedule, ProgramClass};
use kaspa_grpc_client::GrpcClient;
use kaspa_hashes::Hash;
use kaspa_rpc_core::api::rpc::RpcApi;
use kaspa_pow::igneum::{day_index, target64, EpochSeeds, IgneumEngine};
use kaspa_rpc_core::{RpcPowEpochInfo, RpcRawBlock};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
/// The software dev fee's addresses, the solo miner's (`igneum/miner/src/main.rs` DEV_FEE_ADDRESS and
/// DEV_FEE_ADDRESS_DEVNET; copied here because the miner is a binary crate). The open pool's split names one of them
/// at `--open-dev-fee-percent` of the window; every member of a chain must name the same, or its shares mismatch.
pub const DEV_FEE_ADDRESS: &str = "0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126";
pub const DEV_FEE_ADDRESS_DEVNET: &str = "0xdfaea67368f3e3753397d878f97efe6aa8020c2e";
/// The share chain's first target against the network's genesis block target: eight times easier, then the retarget
/// takes over. A chain constant (every member must hold the same, so it never comes from a node's current template).
pub const GENESIS_TARGET_SHIFT: u32 = 3;
/// The devnet's genesis bits (infra/fast-time/README.md: "the devnet value 0x1d100000 is the GPU difficulty"); the
/// testnet and mainnet values are set at their genesis, until then the same.
pub const DEVNET_GENESIS_BITS: u32 = 0x1d10_0000;
/// The first chain target for a network: `target64(genesis bits) << 3`, unless the chain names its own.
pub fn genesis_target_for(cfg: &crate::config::Config) -> u64 {
if let Some(t) = cfg.chain_genesis_target64 {
return t.max(1).min(crate::sidechain::MAX_TARGET);
}
let t = target64(DEVNET_GENESIS_BITS);
t.checked_shl(GENESIS_TARGET_SHIFT).filter(|x| (*x >> GENESIS_TARGET_SHIFT) == t).unwrap_or(u64::MAX >> 1).min(crate::sidechain::MAX_TARGET)
}
#[derive(Clone, Copy, Debug)]
struct EpochRec {
index: u64,
start_daa: u64,
end_daa: u64,
epoch: Hash,
class: ProgramClass,
era: Hash,
reps: u16,
}
pub struct Open {
pub chain: Mutex<ShareChain>,
pub chain_name: String,
pub dev_fee_percent: u32,
pub engine: Arc<IgneumEngine>,
/// The epochs this daemon's node has reported, from its templates: (epoch index, first DAA score, boundary DAA
/// score, epoch seed, class, era seed, ladder rung). A received share names its seeds; they must be one of these,
/// and the share's DAA score must sit within an epoch of that one's span (a member on another node a few DAA
/// scores ahead or behind at a boundary is still that epoch's share)
epochs: Mutex<Vec<EpochRec>>,
/// The member's node (the pool's walker connection): a share naming an epoch seed this node's templates did not
/// is checked against the node's own blocks (the seed is a block hash at the epoch's seed depth)
node: Arc<GrpcClient>,
/// Seed block hash to its DAA score, as the node answered (bounded)
seed_blocks: Mutex<HashMap<Hash, Option<u64>>>,
/// Shares whose parent is not known yet, by parent, waiting for it (bounded)
orphans: Mutex<HashMap<[u8; 32], Vec<Share>>>,
/// New shares for the gossip: (share, origin connection id; 0 = this daemon's own)
pub gossip: tokio::sync::broadcast::Sender<(Arc<Share>, u64)>,
/// Woken when the chain tip moves: every member's job is re-stamped on the new parent
pub tip_changed: tokio::sync::Notify,
log: Mutex<Option<std::fs::File>>,
pub shares_log_path: PathBuf,
withhold: bool,
/// Our own shares' hashes while withholding (never sent, not even on a peer's sync request)
own: Mutex<std::collections::HashSet<[u8; 32]>>,
pub local_shares: AtomicU64,
pub peer_shares: AtomicU64,
pub rejected: AtomicU64,
pub blocks: AtomicU64,
pub last_reject: Mutex<String>,
}
impl Open {
pub fn new(cfg: &crate::config::Config, state_path: &Path, engine: Arc<IgneumEngine>, node: Arc<GrpcClient>) -> Self {
let dev = if cfg.network == "devnet" { DEV_FEE_ADDRESS_DEVNET } else { DEV_FEE_ADDRESS };
let dev_address = crate::protocol::parse_hex_array::<20>(dev).expect("dev fee address");
let params = ChainParams { name: cfg.open_chain.clone(), share_ms: (cfg.chain_share_s * 1000.0).max(100.0) as u64, window: cfg.window_shares, dev_fee_percent: cfg.open_dev_fee_percent, dev_address };
let shares_log_path = state_path.with_file_name("shares.log");
let log = std::fs::OpenOptions::new().create(true).append(true).open(&shares_log_path).ok();
let (gossip, _) = tokio::sync::broadcast::channel(8192);
let mut chain = ShareChain::new(params);
chain.genesis_target = Some(genesis_target_for(cfg));
Self {
chain: Mutex::new(chain),
chain_name: cfg.open_chain.clone(),
dev_fee_percent: cfg.open_dev_fee_percent,
engine,
epochs: Mutex::new(Vec::new()),
node,
seed_blocks: Mutex::new(HashMap::new()),
orphans: Mutex::new(HashMap::new()),
gossip,
tip_changed: tokio::sync::Notify::new(),
log: Mutex::new(log),
shares_log_path,
withhold: cfg.withhold_shares,
own: Mutex::new(std::collections::HashSet::new()),
local_shares: AtomicU64::new(0),
peer_shares: AtomicU64::new(0),
rejected: AtomicU64::new(0),
blocks: AtomicU64::new(0),
last_reject: Mutex::new(String::new()),
}
}
pub fn activation_note(&self) -> &'static str {
"the node's pool_split_activation_daa (blocks below it pay their finder alone; set it in the override file, 0 on a new chain)"
}
fn member_address(member: &Member) -> [u8; 20] {
crate::protocol::parse_hex_array::<20>(&member.address).unwrap_or([0u8; 20])
}
/// What the node is asked for: the member's reveal and its own address (the split is stamped afterwards).
pub fn base_extra_data(&self, member: &Member) -> Vec<u8> {
let mut v = member.reveal.to_extra_data();
v.extend_from_slice(&kaspa_consensus_core::evm::miner_address_extra_data(&Self::member_address(member)));
v
}
/// The share chain's parent, the chain target and the full extra data for a member's template now.
pub fn stamp_for(&self, member: &Member, _info: &RpcPowEpochInfo, raw: &RpcRawBlock) -> Result<([u8; 32], u64, Vec<u8>), String> {
let chain = self.chain.lock().unwrap();
let _ = raw;
let parent = chain.tip.unwrap_or(GENESIS_PARENT);
let target = chain.target_after(&parent).ok_or("no chain target")?;
let extra = chain.extra_data_for(&member.reveal, Self::member_address(member), &parent, target);
Ok((parent, target, extra))
}
/// Remembers the current and the next epoch's seeds from a template, for checking peers' shares.
pub fn note_epoch(&self, info: &RpcPowEpochInfo, seeds: &EpochSeeds) {
let mut e = self.epochs.lock().unwrap();
let blocks = info.epoch_blocks.max(1);
let mut put = |r: EpochRec| {
if let Some(x) = e.iter_mut().find(|x| x.index == r.index) {
*x = r;
} else {
e.push(r);
}
};
put(EpochRec { index: info.epoch_index, start_daa: info.boundary_daa_score.saturating_sub(blocks), end_daa: info.boundary_daa_score, epoch: seeds.epoch, class: seeds.class, era: seeds.era, reps: seeds.shadow_reps });
if let Some(next) = info.next_epoch_seed {
put(EpochRec { index: info.epoch_index + 1, start_daa: info.boundary_daa_score, end_daa: info.boundary_daa_score + blocks, epoch: next, class: info.next_class(), era: seeds.era, reps: info.next_latency_ladder_reps as u16 });
}
if e.len() > 64 {
let min = info.epoch_index.saturating_sub(48);
e.retain(|r| r.index >= min);
}
}
/// Checks a share's named seeds. The epoch seed is a block hash (the devnet rule: the last selected-chain block
/// below the epoch's start less the lead), and on a DAG two nodes can name two seed blocks for one epoch while the
/// chain below the lead is still settling (the 100-member gate on the 60x profile, 7 October 2026: four nodes,
/// three seeds for epoch 1, no block refused by any node, because a node checks a block's PoW under the seed of
/// the block's own ancestry). So a share's seeds are accepted when they are what this daemon's node reported for
/// the share's epoch (the fast path), or when the named seed is a block this daemon's node holds at that epoch's
/// seed depth (within one epoch below the epoch's start less the lead) with the epoch's class, era and rung: the
/// freedom a share has is the freedom a block producer has, a handful of tips at one depth, never a chosen program.
/// A share of an epoch this daemon's node has not reported at all is refused (O-9.11). The first version keyed the
/// node's epochs by `DAA / epoch_blocks`, which is not how the node numbers them, and refused every share past
/// epoch 1; the second required the node's own seed exactly, and refused every share from another node's view.
fn seeds_for(&self, s: &Share) -> Result<EpochSeeds, String> {
let wire = s.seeds.to_seeds()?;
let blocks = pow_epoch_blocks().max(1);
let lead = pow_schedule().epoch_lead;
let (known, covering) = {
let e = self.epochs.lock().unwrap();
let known = e.iter().find(|r| r.epoch == wire.epoch && r.class == wire.class && r.era == wire.era && r.reps == wire.shadow_reps).copied();
let covering = e.iter().filter(|r| s.daa_score >= r.start_daa && s.daa_score < r.end_daa).copied().collect::<Vec<_>>();
(known, covering)
};
if let Some(r) = known {
if s.daa_score + blocks < r.start_daa || s.daa_score > r.end_daa + blocks {
return Err(format!("the share's DAA score {} is outside epoch {}'s span {}..{} (within an epoch either side) for the seeds it names", s.daa_score, r.index, r.start_daa, r.end_daa));
}
return Ok(EpochSeeds { epoch: r.epoch, day: day_index(s.timestamp), class: r.class, era: r.era, shadow_reps: r.reps });
}
let Some(r) = covering.first().copied() else {
return Err(format!("epoch of DAA {} is not one this daemon's node has reported", s.daa_score));
};
if wire.class != r.class || wire.era != r.era || wire.shadow_reps != r.reps {
return Err(format!("the share names class {} era {} rung {} for epoch {}; the node's are class {} era {} rung {}", wire.class.name(), &s.seeds.era[..16], wire.shadow_reps, r.index, r.class.name(), r.era, r.reps));
}
// another node's view of the same epoch: the seed must be a block this node holds at the seed depth
let seed_daa = {
let cached = self.seed_blocks.lock().unwrap().get(&wire.epoch).copied();
match cached {
Some(v) => v,
None => {
let node = self.node.clone();
let h = wire.epoch;
let r = tokio::runtime::Handle::current().block_on(async move { tokio::time::timeout(std::time::Duration::from_secs(5), node.get_block(h, false)).await });
let v = match r {
Ok(Ok(b)) => Some(b.header.daa_score),
Ok(Err(e)) => {
println!("{} OPEN seed block {} asked of the node: {e}", unix_ms(), &s.seeds.epoch[..16]);
None
}
Err(_) => {
println!("{} OPEN seed block {} asked of the node: timed out after 5 s", unix_ms(), &s.seeds.epoch[..16]);
None
}
};
let mut c = self.seed_blocks.lock().unwrap();
if v.is_some() {
c.insert(wire.epoch, v);
if c.len() > 4096 {
c.clear();
}
}
v
}
}
};
let Some(seed_daa) = seed_daa else {
return Err(format!("the share names epoch seed {} for epoch {}, which is neither the node's seed nor a block the node holds", &s.seeds.epoch[..16], r.index));
};
let seed_ceiling = r.start_daa.saturating_sub(lead);
if seed_daa >= seed_ceiling || seed_daa + blocks + lead < r.start_daa {
return Err(format!("the share names epoch seed {} (a block at DAA {}) for epoch {}, outside the seed depth {}..{}", &s.seeds.epoch[..16], seed_daa, r.index, r.start_daa.saturating_sub(lead + blocks), seed_ceiling));
}
Ok(EpochSeeds { epoch: wire.epoch, day: day_index(s.timestamp), class: r.class, era: r.era, shadow_reps: r.reps })
}
/// A share of this daemon's own member: logged, chained, gossiped (unless withheld, the gate's test flag).
#[allow(clippy::too_many_arguments)]
pub fn local_share(self: &Arc<Self>, pool: &Arc<Pool>, member: &Arc<Member>, raw: &RpcRawBlock, nonce: u64, lane: u64, key: &JobKey, target: u64, parent: [u8; 32]) {
let mut raw = raw.clone();
raw.header.nonce = nonce;
let Ok(block) = Block::try_from(raw.clone()) else { return };
let height = {
let c = self.chain.lock().unwrap();
if parent == GENESIS_PARENT { 0 } else { c.get(&parent).map(|p| p.height + 1).unwrap_or(0) }
};
let share = Share {
hash: block.hash().to_string(),
parent: hex::encode(parent),
height,
address: member.address.clone(),
key_hash: member.key_hash.to_string(),
timestamp: raw.header.timestamp,
daa_score: raw.header.daa_score,
nonce: format!("{nonce:016x}"),
lane_hash: format!("{lane:016x}"),
share_target64: format!("{target:016x}"),
block_target64: format!("{:016x}", key.target64),
seeds: SeedsWire::from_seeds(&key.seeds),
raw,
received_ms: unix_ms(),
};
// the member's own log: every share it found, self-certifying (the drop proof, `verify-share`)
if let Some(f) = self.log.lock().unwrap().as_mut() {
let _ = writeln!(f, "SHARE {}", share.line());
}
let (me, pool2) = (self.clone(), pool.clone());
let is_block = share.is_block();
tokio::task::spawn_blocking(move || match me.accept(share, 0) {
Ok((tip, s)) => {
me.local_shares.fetch_add(1, Ordering::Relaxed);
println!("{} OPEN SHARE {} height {} by {} tip={tip}{}{}", unix_ms(), &s.hash[..16], s.height, &s.address[..10], if is_block { " BLOCK" } else { "" }, if me.withhold { " (withheld: not gossiped)" } else { "" });
if tip {
me.tip_changed.notify_one();
}
let _ = pool2;
}
Err(e) => {
me.rejected.fetch_add(1, Ordering::Relaxed);
println!("{} OPEN SHARE of our own REFUSED by our own chain: {e}", unix_ms());
}
});
}
/// Checks and inserts a share (structure, seeds, PoW), then any orphans that waited for it. Returns whether the
/// tip moved and the share. Blocking (the PoW check); callers run it off the async threads.
pub fn accept(self: &Arc<Self>, share: Share, origin: u64) -> Result<(bool, Arc<Share>), String> {
let hash = share.hash32();
{
let c = self.chain.lock().unwrap();
if c.shares.contains_key(&hash) {
return Err("already known".into());
}
c.check_structure(&share).map_err(|e| {
if e.contains("parent") && e.contains("unknown") {
let mut o = self.orphans.lock().unwrap();
if o.values().map(|v| v.len()).sum::<usize>() < 20_000 {
o.entry(share.parent32()).or_default().push(share.clone());
}
}
e
})?;
}
let seeds = self.seeds_for(&share)?;
let wire = share.seeds.to_seeds()?;
if wire != seeds {
return Err(format!("the share's day {} is not the day of its timestamp ({})", wire.day, seeds.day));
}
check_pow(&self.engine, &share)?;
let share = Arc::new(share);
let tip = {
let mut c = self.chain.lock().unwrap();
if c.shares.contains_key(&hash) {
return Err("already known".into());
}
c.insert(share.clone())
};
if origin != 0 {
self.peer_shares.fetch_add(1, Ordering::Relaxed);
}
if origin == 0 && self.withhold {
self.own.lock().unwrap().insert(hash);
} else {
let _ = self.gossip.send((share.clone(), origin));
}
// orphans that waited for this share
let waiting = self.orphans.lock().unwrap().remove(&hash).unwrap_or_default();
let mut tip_moved = tip;
for w in waiting {
if let Ok((t, _)) = self.accept(w, origin) {
tip_moved |= t;
}
}
if tip_moved && origin != 0 {
self.tip_changed.notify_one();
}
Ok((tip_moved, share))
}
/// A block this daemon's member found: recorded with the split its coinbase carries (the coinbase pays it).
#[allow(clippy::too_many_arguments)]
pub fn note_block(&self, raw: &RpcRawBlock, hash: &str, daa_score: u64, blue_score: u64, reward_wei: u128, finder: &str, state: &mut State) {
let split = raw.transactions.first().and_then(|cb| payout_split_in(&cb.payload)).unwrap_or_default();
let total: u64 = split.iter().map(|(_, w)| *w as u64).sum::<u64>().max(1);
let payees = split.iter().map(|(a, w)| crate::pplns::Payee { address: format!("0x{}", hex::encode(a)), fraction: *w as f64 / total as f64 }).collect();
self.blocks.fetch_add(1, Ordering::Relaxed);
state.block_found_with(BlockRec {
hash: hash.to_string(),
daa_score,
blue_score,
found_ms: unix_ms(),
finder: finder.to_string(),
worker: "open".into(),
status: "pending".into(),
reward_wei,
fee_wei: 0,
effort: 0.0,
payees,
confirmed_ms: None,
nonce: format!("{:016x}", raw.header.nonce),
shift: 0,
});
}
pub fn status(&self) -> serde_json::Value {
let c = self.chain.lock().unwrap();
let tip = c.tip_share();
serde_json::json!({
"chain": self.chain_name,
"height": c.height(),
"tip": tip.as_ref().map(|t| t.hash.clone()),
"tip_age_s": tip.as_ref().map(|t| unix_ms().saturating_sub(t.received_ms) / 1000),
"target64": tip.as_ref().map(|t| t.share_target64.clone()).or_else(|| c.genesis_target.map(|g| format!("{g:016x}"))),
"next_target64": c.tip.map(|t| c.target_after(&t)).unwrap_or(c.genesis_target).map(|t| format!("{t:016x}")),
"work": c.tip_work().to_string(),
"shares_known": c.shares.len(),
"accepted": c.accepted, "stale": c.stale, "reorgs": c.reorgs,
"local_shares": self.local_shares.load(Ordering::Relaxed), "peer_shares": self.peer_shares.load(Ordering::Relaxed), "rejected": self.rejected.load(Ordering::Relaxed),
"blocks_found": self.blocks.load(Ordering::Relaxed),
"window_shares": c.params.as_ref().map(|p| p.window).unwrap_or(0),
"share_ms": c.params.as_ref().map(|p| p.share_ms).unwrap_or(0),
"dev_fee_percent": self.dev_fee_percent,
"withhold": self.withhold,
"window": c.window_fractions().into_iter().map(|(a, f)| serde_json::json!({"address": a, "fraction": (f * 1e6).round() / 1e6})).collect::<Vec<_>>(),
"last_reject": self.last_reject.lock().unwrap().clone(),
})
}
/// The shares a peer's sync gets: the tip's ancestry from `from`, without our own while withholding.
pub fn sync_range(&self, from: u64, count: usize) -> Vec<Arc<Share>> {
let c = self.chain.lock().unwrap();
let v = c.range(from, count);
if !self.withhold {
return v;
}
let own = self.own.lock().unwrap();
v.into_iter().filter(|s| !own.contains(&s.hash32())).collect()
}
pub fn shares_json(&self, from: u64, count: usize) -> serde_json::Value {
let c = self.chain.lock().unwrap();
serde_json::json!({ "ok": true, "shares": c.range(from, count.min(2048)).iter().map(|s| serde_json::json!({"hash": s.hash, "parent": s.parent, "height": s.height, "address": s.address, "timestamp": s.timestamp, "daa_score": s.daa_score, "share_target64": s.share_target64, "block": s.is_block()})).collect::<Vec<_>>() })
}
pub fn share_json(&self, hash: &str) -> Option<serde_json::Value> {
let h = crate::sidechain::parse32(hash)?;
let c = self.chain.lock().unwrap();
c.get(&h).map(|s| serde_json::to_value(&*s).unwrap_or_default())
}
}

224
pool/src/p2p.rs Normal file
View file

@ -0,0 +1,224 @@
//! The share chain's gossip (the open pool, `open.rs`): newline JSON over TCP between members' daemons, the same
//! framing as the member protocol. `--p2p-listen` accepts, `--peer host:port` connects (reconnecting with backoff).
//! Messages (`t`): `hello` (chain name, tip, height, cumulative work), `share` (one `sidechain::Share`), `get_shares`
//! (`from` height), `ping`, `pong`, `bye`. A peer whose hello names another chain is dropped. A peer with more work
//! than ours is asked for the shares from 64 below our height; every valid share is relayed to every other peer.
//!
//! No peer is trusted: every share is checked by `Open::accept` (structure, the node's seeds, the PoW) before it
//! enters the chain or leaves this daemon again. The gossip is members only; nodes never see a share.
use crate::open::Open;
use crate::pool::Pool;
use crate::protocol::MAX_LINE;
use crate::sidechain::Share;
use crate::state::unix_ms;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
/// Shares asked for below our height when a peer is ahead; each further round of unknown parents asks this much
/// deeper, down to the chain's reorg depth.
pub const SYNC_BACK: u64 = 64;
pub async fn run(pool: Arc<Pool>, open: Arc<Open>) {
let listen = pool.cfg.p2p_listen.clone();
match tokio::net::TcpListener::bind(&listen).await {
Ok(l) => {
println!("{} p2p: share chain '{}' listening on {listen}", unix_ms(), open.chain_name);
let (pool2, open2) = (pool.clone(), open.clone());
tokio::spawn(async move {
loop {
match l.accept().await {
Ok((sock, addr)) => {
let (p, o) = (pool2.clone(), open2.clone());
tokio::spawn(async move {
let _ = sock.set_nodelay(true);
if let Err(e) = session(p, o, sock, addr.to_string()).await {
println!("{} p2p {addr}: {e}", unix_ms());
}
});
}
Err(e) => {
eprintln!("{} p2p accept: {e}", unix_ms());
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
}
});
}
Err(e) => eprintln!("{} p2p: cannot listen on {listen}: {e}; outbound peers only", unix_ms()),
}
for peer in pool.cfg.peers.clone() {
let (p, o) = (pool.clone(), open.clone());
tokio::spawn(async move {
let mut backoff = 1u64;
loop {
match tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(&peer)).await {
Ok(Ok(sock)) => {
let _ = sock.set_nodelay(true);
backoff = 1;
match session(p.clone(), o.clone(), sock, peer.clone()).await {
Ok(()) => println!("{} p2p {peer}: closed", unix_ms()),
Err(e) => println!("{} p2p {peer}: {e}", unix_ms()),
}
}
Ok(Err(e)) => {
if backoff <= 2 {
println!("{} p2p {peer}: connect failed ({e}); retrying", unix_ms());
}
}
Err(_) => println!("{} p2p {peer}: connect timed out", unix_ms()),
}
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(30);
}
});
}
}
fn hello(open: &Open) -> Value {
let c = open.chain.lock().unwrap();
json!({"t": "hello", "chain": open.chain_name, "tip": c.tip.map(hex::encode), "height": c.height(), "work": c.tip_work().to_string(), "version": crate::protocol::VERSION})
}
async fn session(pool: Arc<Pool>, open: Arc<Open>, sock: TcpStream, remote: String) -> Result<(), String> {
let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
let (rd, mut wr) = sock.into_split();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let writer = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
if wr.write_all(line.as_bytes()).await.is_err() {
break;
}
}
});
let send = |v: Value| {
let mut s = v.to_string();
s.push('\n');
let _ = tx.send(s);
};
send(hello(&open));
// the relay: every share that enters this daemon from elsewhere goes to this peer
let mut gossip = open.gossip.subscribe();
let mut lines = BufReader::with_capacity(256 << 10, rd).lines();
let mut asked_at = 0u64;
let mut asked_from: Option<u64> = None;
let mut relayed = 0u64;
let mut received = 0u64;
let mut last_ping = tokio::time::Instant::now();
let _ = pool;
loop {
tokio::select! {
l = tokio::time::timeout(Duration::from_secs(120), lines.next_line()) => {
let l = match l {
Ok(Ok(Some(l))) => l,
Ok(Ok(None)) => break,
Ok(Err(e)) => return Err(format!("read: {e}")),
Err(_) => return Err("idle 120 s".into()),
};
if l.len() > MAX_LINE {
return Err("line over 4 MiB".into());
}
let v: Value = match serde_json::from_str(&l) {
Ok(v) => v,
Err(_) => continue,
};
match v.get("t").and_then(|t| t.as_str()).unwrap_or("") {
"hello" => {
if v["chain"].as_str() != Some(open.chain_name.as_str()) {
send(json!({"t": "bye", "reason": format!("chain {} here", open.chain_name)}));
return Err(format!("peer is on chain {:?}, we are on {}", v["chain"].as_str(), open.chain_name));
}
let peer_height = v["height"].as_u64().unwrap_or(0);
let peer_work: u128 = v["work"].as_str().and_then(|w| w.parse().ok()).unwrap_or(0);
let (ours, height) = { let c = open.chain.lock().unwrap(); (c.tip_work(), c.height()) };
println!("{} p2p {remote}: hello, height {peer_height} (ours {height}){}", unix_ms(), if peer_work > ours { ", ahead: asking for shares" } else { "" });
if peer_work > ours {
asked_at = unix_ms();
asked_from = Some(height.saturating_sub(SYNC_BACK));
send(json!({"t": "get_shares", "from": height.saturating_sub(SYNC_BACK)}));
}
}
"get_shares" => {
let from = v["from"].as_u64().unwrap_or(0);
let shares = open.sync_range(from, 4096);
println!("{} p2p {remote}: sending {} shares from height {from}", unix_ms(), shares.len());
for s in shares {
send(share_msg(&s));
}
send(json!({"t": "synced", "from": from}));
}
"share" => {
let Ok(share) = serde_json::from_value::<Share>(v["share"].clone()) else { continue };
received += 1;
let (o, s2) = (open.clone(), share.clone());
let r = tokio::task::spawn_blocking(move || o.accept(s2, conn_id)).await.map_err(|e| e.to_string())?;
match r {
Ok((tip, s)) => {
if pool.cfg.name.contains("verbose") || s.is_block() {
println!("{} p2p {remote}: share {} height {} by {}{}{}", unix_ms(), &s.hash[..16], s.height, &s.address[..10], if tip { " (tip)" } else { "" }, if s.is_block() { " BLOCK" } else { "" });
}
}
Err(e) if e == "already known" => {}
Err(e) => {
*open.last_reject.lock().unwrap() = format!("{remote}: {e}");
open.rejected.fetch_add(1, Ordering::Relaxed);
if e.contains("unknown") && unix_ms().saturating_sub(asked_at) > 2000 {
// the parent is missing: ask for the stretch below it, and each time it is still
// missing a stretch deeper (a fork older than the last request), down to the
// chain's reorg depth
let floor = { open.chain.lock().unwrap().height() }.saturating_sub(crate::sidechain::MAX_REORG_DEPTH);
let from = match asked_from {
Some(f) if f < share.height => f.saturating_sub(SYNC_BACK).max(floor),
_ => share.height.saturating_sub(SYNC_BACK).max(floor),
};
asked_at = unix_ms();
asked_from = Some(from);
send(json!({"t": "get_shares", "from": from}));
} else if !e.contains("unknown") {
println!("{} p2p {remote}: share {} REFUSED: {e}", unix_ms(), &share.hash[..16.min(share.hash.len())]);
}
}
}
}
"synced" => { asked_from = None; }
"ping" => send(json!({"t": "pong", "id": v["id"]})),
"pong" => {}
"bye" => return Err(format!("peer said bye: {}", v["reason"].as_str().unwrap_or(""))),
_ => {}
}
}
g = gossip.recv() => {
match g {
Ok((share, origin)) => {
if origin != conn_id {
send(share_msg(&share));
relayed += 1;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
println!("{} p2p {remote}: relay lagged {n} shares; the peer will ask for the gap", unix_ms());
}
Err(_) => break,
}
}
_ = tokio::time::sleep(Duration::from_secs(30)) => {}
}
if last_ping.elapsed() >= Duration::from_secs(30) {
last_ping = tokio::time::Instant::now();
send(json!({"t": "ping", "id": unix_ms()}));
}
}
drop(tx);
let _ = writer.await;
println!("{} p2p {remote}: session over (received {received}, relayed {relayed})", unix_ms());
Ok(())
}
fn share_msg(s: &Share) -> Value {
json!({"t": "share", "share": s})
}

View file

@ -64,6 +64,22 @@ impl EvmRpc {
Ok(Self { host, port, path: path.to_string(), id: Mutex::new(0) })
}
/// One POST of a JSON body (the Q72 webhook); the response body is ignored past its status.
pub async fn post_json(&self, body: &Value) -> Result<(), String> {
let body = serde_json::to_vec(body).unwrap();
let req = format!("POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", self.path, self.host, self.port, body.len());
let fut = async {
let mut s = tokio::net::TcpStream::connect((self.host.as_str(), self.port)).await.map_err(|e| format!("connect {}:{}: {e}", self.host, self.port))?;
s.write_all(req.as_bytes()).await.map_err(|e| e.to_string())?;
s.write_all(&body).await.map_err(|e| e.to_string())?;
let mut buf = Vec::new();
s.read_to_end(&mut buf).await.map_err(|e| e.to_string())?;
Ok::<Vec<u8>, String>(buf)
};
let raw = tokio::time::timeout(std::time::Duration::from_secs(15), fut).await.map_err(|_| "webhook timed out".to_string())??;
http_body(&raw).map(|_| ())
}
pub async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
let id = {
let mut g = self.id.lock().unwrap();

View file

@ -1,6 +1,7 @@
//! The live pool: configuration, the share verifier engine, the ledger, the connected members and their jobs.
use crate::config::Config;
use crate::open::Open;
use crate::state::State;
use crate::vardiff::Vardiff;
use crate::verify::JobKey;
@ -28,6 +29,9 @@ pub struct JobRec {
pub seen: HashSet<u64>,
pub daa_score: u64,
pub blue_score: u64,
/// The open pool: the share-chain parent and the split this template commits to (`open.rs`)
pub open_parent: Option<[u8; 32]>,
pub open_share_target64: u64,
}
pub struct MemberInner {
@ -48,6 +52,8 @@ pub struct MemberInner {
pub proving: bool,
pub current_target64: u64,
pub in_flight_checks: u32,
/// The node's last template for this member with its epoch info (the open pool re-stamps it on a new parent)
pub last_template: Option<(RpcRawBlock, kaspa_rpc_core::RpcPowEpochInfo)>,
}
pub struct Member {
@ -89,6 +95,11 @@ pub struct NetInfo {
pub updated_ms: u64,
pub epoch_seed: String,
pub epoch_index: u64,
/// Q73: the network's finality as the node reports it (`active`, `paused`, `window filling, N of M`, or
/// `unknown` when the node does not answer), the latest locked index and its age in seconds
pub finality: String,
pub finality_locked_index: u64,
pub finality_locked_age_s: u64,
}
pub struct Pool {
@ -100,6 +111,10 @@ pub struct Pool {
pub next_template_id: AtomicU64,
pub next_job_id: AtomicU64,
pub node: Arc<GrpcClient>,
/// A second connection to the same node for the confirmation walk and the network numbers (7 October 2026,
/// 06:01Z and 06:12:55Z: the walk's `get_block` calls shared the template connection and starved every template
/// request; the gRPC client is one request stream per connection)
pub walker: Arc<GrpcClient>,
pub extra_nodes: Vec<Arc<GrpcClient>>,
/// The pool's EVM coinbase address, lowercase 0x hex, named in every template's `IGNA` field
pub pool_address: [u8; 20],
@ -108,7 +123,17 @@ pub struct Pool {
pub net: Mutex<NetInfo>,
pub want_templates: tokio::sync::Notify,
pub verify_permits: tokio::sync::Semaphore,
/// Template fetches in flight against the node (`--template-parallel`)
pub template_permits: tokio::sync::Semaphore,
pub started: Instant,
/// Template fetches that failed or timed out since start, and the unix ms of the last one that succeeded (0 =
/// never): a daemon whose node answers no template issues no job, and the STATUS line in the log says so
pub template_failures: AtomicU64,
pub last_template_ok_ms: AtomicU64,
/// TLS 1.3 on the member port (spec 9.3), when configured
pub tls: Option<crate::tls::Tls>,
/// The open pool's share chain and gossip, in `--open` mode
pub open: Option<Arc<Open>>,
}
impl Pool {

View file

@ -38,6 +38,13 @@ pub enum Msg {
share_interval_s: u64,
stale_grace_ms: u64,
pool_name: String,
/// `operator` (pool-0: the pool's address in every coinbase, PPLNS from its balance) or `open` (the share
/// sidechain: every coinbase pays the window, no balance, no operator key)
#[serde(default)]
pool_mode: String,
/// Spec 9.3: whether this connection is TLS (the `authorize` binding is then required)
#[serde(default)]
tls: bool,
},
Authorize {
/// 48 bytes hex, the member's BLS vote key
@ -48,7 +55,8 @@ pub enum Msg {
label: String,
/// EVM payout address, 0x + 40 hex (v0 addition)
payout: String,
/// `binding` of spec 9.3 (a signature over the TLS exporter) is absent in v0: no TLS yet
/// Spec 9.3, O-9.7: over TLS, the member's BLS signature (96 bytes hex) over this connection's exporter
/// (`finality::binding_message`); empty over plain TCP
#[serde(default)]
binding: String,
},
@ -79,6 +87,11 @@ pub enum Msg {
genesis_day_index: u64,
genesis_dataset_log2: u32,
program_class_v3_activation_daa: u64,
/// The latency ladder (0.3.19): the shadow pass count of the current and the next epoch, part of the program
#[serde(default)]
shadow_reps: u32,
#[serde(default)]
next_shadow_reps: u32,
},
SetTarget {
shift: u32,
@ -107,6 +120,9 @@ pub enum Msg {
/// worker lines use exactly these (never a fixed class)
program_class: u32,
era_seed: Option<String>,
/// The latency ladder's shadow pass count of the job's epoch (0 before the ladder)
#[serde(default)]
shadow_reps: u32,
clean: bool,
},
JobRefused {
@ -171,6 +187,16 @@ pub struct ShareScheme {
pub fee_percent: f64,
pub pplns_window_blocks: f64,
pub min_payout_ign: f64,
/// Published beside the pool fee (reinvent 3.5): the software dev fee a member pays in this mode. Pool-0: 0
/// (the pool issues the templates, its fee is the only fee); the open pool: the split entry to the software
/// address, the same 1 percent the solo miner pays, so solo, pool-0 and open are fee-neutral
#[serde(default)]
pub software_dev_fee_percent: f64,
/// The open pool: the window in shares and the chain's seconds per share
#[serde(default)]
pub window_shares: u64,
#[serde(default)]
pub chain_share_s: f64,
}
impl Msg {

View file

@ -1,37 +1,70 @@
//! Member connections: newline JSON over TCP (spec 9.3 wants TLS 1.3; v0 listens in the clear and the README puts a
//! TLS terminator in front, docs/plans/pool.md). One reader task and one writer task per connection.
//! Member connections: newline JSON over TLS 1.3 (spec 9.3, `tls.rs`: `--tls-cert`/`--tls-key` or `--tls-self-signed`)
//! or over plain TCP (the devnet default; testnet and mainnet refuse plain without `--allow-plain`). One reader task
//! and one writer task per connection. Over TLS the `authorize` binding (O-9.7) is checked against this connection's
//! exporter, so an `authorize` replayed on another connection is refused.
use crate::node::{fetch_job, submit_block};
use crate::pool::{Member, MemberInner, Pool};
use crate::protocol::{hex_u64, parse_hex_array, parse_hex_u64, Msg, ShareScheme, MAX_LINE, VERSION};
use crate::vardiff::{share_target, share_weight, Vardiff};
use crate::verify::{check, Code};
use kaspa_consensus_core::finality::{verify_pop, KeyReveal};
use kaspa_consensus_core::finality::{verify_binding, verify_pop, KeyReveal, BINDING_EXPORTER_LEN};
use std::collections::VecDeque;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
fn now() -> String {
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap();
format!("{}.{:03}", t.as_secs(), t.subsec_millis())
}
pub async fn listen(pool: Arc<Pool>) {
let listener = tokio::net::TcpListener::bind(&pool.cfg.listen).await.unwrap_or_else(|e| {
eprintln!("cannot listen on {}: {e}", pool.cfg.listen);
std::process::exit(1)
});
println!("{} pool: members on {} (chain id {}, {})", now(), pool.cfg.listen, pool.cfg.chain_id(), pool.cfg.network);
/// Binds a listener or says exactly why not. Called from `main` BEFORE anything else starts (7 October 2026, the fleet
/// agent's row from the 6 October night: a daemon whose member port another process still held ran for 20 minutes as a
/// process with no socket; now a bind that fails ends the process with exit code 2 before a member can be let down).
pub async fn bind_listener(what: &str, addr: &str) -> Result<tokio::net::TcpListener, String> {
tokio::net::TcpListener::bind(addr).await.map_err(|e| format!("cannot bind the {what} listener on {addr}: {e} (another process holds the port, or the address is not this machine's)"))
}
pub async fn listen(pool: Arc<Pool>, listener: tokio::net::TcpListener) {
println!(
"{} pool: members on {} (chain id {}, {}, {})",
now(),
pool.cfg.listen,
pool.cfg.chain_id(),
pool.cfg.network,
match &pool.tls {
Some(t) => format!("TLS 1.3, certificate pin {}{}", t.pin, if t.self_signed { " (self-signed: members pass it as --pool-pin)" } else { "" }),
None => "PLAIN TCP: spec 9.3 wants TLS 1.3 (--tls-self-signed or --tls-cert/--tls-key)".to_string(),
}
);
loop {
match listener.accept().await {
Ok((sock, addr)) => {
let pool = pool.clone();
tokio::spawn(async move {
let _ = sock.set_nodelay(true);
connection(pool, sock, addr.to_string()).await;
match &pool.tls {
Some(t) => {
let acceptor = t.acceptor.clone();
match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(sock)).await {
Ok(Ok(tls)) => {
let exporter = match crate::tls::exporter(tls.get_ref().1, pool.cfg.chain_id()) {
Ok(e) => e,
Err(e) => {
eprintln!("{} {addr}: {e}", now());
return;
}
};
connection(pool, tls, addr.to_string(), Some(exporter)).await;
}
Ok(Err(e)) => eprintln!("{} {addr}: TLS handshake failed: {e} (a member without --pool-tls or --pool-pin, or the wrong pin)", now()),
Err(_) => eprintln!("{} {addr}: TLS handshake timed out", now()),
}
}
None => connection(pool, sock, addr.to_string(), None).await,
}
});
}
Err(e) => {
@ -50,17 +83,38 @@ fn welcome(pool: &Pool) -> Msg {
pool_address: pool.pool_address_hex.clone(),
modes: vec!["A".into()],
vote_mode: "member".into(),
share_scheme: ShareScheme { scheme: "pplns".into(), fee_percent: pool.cfg.fee_percent, pplns_window_blocks: pool.cfg.pplns_window_blocks, min_payout_ign: pool.cfg.min_payout_ign },
share_scheme: match &pool.open {
Some(o) => ShareScheme {
scheme: "pplns-sidechain".into(),
fee_percent: 0.0,
pplns_window_blocks: 0.0,
min_payout_ign: 0.0,
software_dev_fee_percent: o.dev_fee_percent as f64,
window_shares: pool.cfg.window_shares as u64,
chain_share_s: pool.cfg.chain_share_s,
},
None => ShareScheme {
scheme: "pplns".into(),
fee_percent: pool.cfg.fee_percent,
pplns_window_blocks: pool.cfg.pplns_window_blocks,
min_payout_ign: pool.cfg.min_payout_ign,
software_dev_fee_percent: 0.0,
window_shares: 0,
chain_share_s: 0.0,
},
},
min_shift: pool.cfg.min_shift,
max_shift: pool.cfg.max_shift,
share_interval_s: pool.cfg.share_interval_s,
stale_grace_ms: pool.cfg.stale_grace_ms,
pool_name: pool.cfg.name.clone(),
pool_mode: if pool.open.is_some() { "open".into() } else { "operator".into() },
tls: pool.tls.is_some(),
}
}
async fn connection(pool: Arc<Pool>, sock: TcpStream, remote: String) {
let (rd, mut wr) = sock.into_split();
async fn connection<S: AsyncRead + AsyncWrite + Send + 'static>(pool: Arc<Pool>, sock: S, remote: String, exporter: Option<[u8; BINDING_EXPORTER_LEN]>) {
let (rd, mut wr) = tokio::io::split(sock);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let writer = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
@ -112,7 +166,7 @@ async fn connection(pool: Arc<Pool>, sock: TcpStream, remote: String) {
println!("{} {remote}: hello from {client}", now());
let _ = tx.send(welcome(&pool).line());
}
Msg::Authorize { pubkey, pop, label, payout, .. } => {
Msg::Authorize { pubkey, pop, label, payout, binding } => {
if !said_hello {
let _ = tx.send(Msg::Error { code: "order".into(), detail: "hello first".into() }.line());
continue;
@ -125,6 +179,15 @@ async fn connection(pool: Arc<Pool>, sock: TcpStream, remote: String) {
let _ = tx.send(Msg::Bye { reason: "proof of possession does not verify".into() }.line());
break;
}
// spec 9.3, O-9.7: over TLS the binding must be this member's signature over THIS connection's exporter
if let Some(e) = &exporter {
let ok = parse_hex_array::<96>(&binding).is_some_and(|sig| verify_binding(&pk, pool.cfg.chain_id(), e, &sig));
if !ok {
println!("{} {remote}: authorize REFUSED: the binding does not sign this connection's TLS exporter (a replay, or a member without the binding)", now());
let _ = tx.send(Msg::Bye { reason: "binding: the authorize must sign this connection's TLS exporter under your key (spec 9.3)".into() }.line());
break;
}
}
let Some(addr) = parse_hex_array::<20>(&payout) else {
let _ = tx.send(Msg::Bye { reason: "payout is 0x followed by 40 hex".into() }.line());
break;
@ -160,6 +223,7 @@ async fn connection(pool: Arc<Pool>, sock: TcpStream, remote: String) {
proving: false,
current_target64: 0,
in_flight_checks: 0,
last_template: None,
}),
});
pool.members.lock().unwrap().insert(id, m.clone());
@ -254,12 +318,12 @@ async fn handle_share(pool: &Arc<Pool>, m: &Arc<Member>, job_id: u64, nonce: u64
} else if j.superseded_at.is_some_and(|t| t.elapsed() > grace) {
Err(Code::Stale)
} else {
Ok((j.key.clone(), j.epoch.clone(), j.shift, j.raw.clone(), j.daa_score, j.blue_score))
Ok((j.key.clone(), j.epoch.clone(), j.shift, j.raw.clone(), j.daa_score, j.blue_score, j.open_parent, j.open_share_target64))
}
}
}
};
let (key, epoch, shift, raw, daa, blue) = match found {
let (key, epoch, shift, raw, daa, blue, open_parent, open_target) = match found {
Ok(x) => x,
Err(Code::Duplicate) if is_solution => return,
Err(code) => {
@ -311,6 +375,13 @@ async fn handle_share(pool: &Arc<Pool>, m: &Arc<Member>, job_id: u64, nonce: u64
g.vardiff.retarget(now_s, t64).map(|s| (s, share_target(t64, s)))
};
m.send(Msg::ShareResult { job_id, nonce: hex_u64(nonce), accepted: true, code: "ok".into(), weight, block: v.block }.line());
// the open pool: a hash at or below the chain's target is a share of the share chain (open.rs)
if let (Some(o), Some(parent)) = (&pool.open, open_parent)
&& open_target > 0
&& v.hash <= open_target
{
o.local_share(pool, m, &raw, nonce, v.hash, &key, open_target, parent);
}
if let Some((s, st)) = retarget {
m.send(Msg::SetTarget { shift: s, share_target64: hex_u64(st) }.line());
println!("{} VARDIFF member {} ({}) shift -> {} (share target {:016x})", now(), m.id, m.worker, s, st);
@ -323,3 +394,20 @@ async fn handle_share(pool: &Arc<Pool>, m: &Arc<Member>, job_id: u64, nonce: u64
tokio::spawn(submit_block(pool.clone(), m.clone(), raw, nonce, shift, daa, blue));
}
}
#[cfg(test)]
mod bind_tests {
use super::bind_listener;
/// Known good: a free port binds. Known failed: the same port, held, is refused with the address in the message
/// (the shape the fleet saw on 6 October: a node still holding 4463).
#[tokio::test]
async fn a_held_port_is_refused_loudly_and_a_free_one_binds() {
let held = bind_listener("members", "127.0.0.1:0").await.expect("a free port binds");
let addr = held.local_addr().unwrap().to_string();
let err = bind_listener("members", &addr).await.err().expect("the held port is refused");
assert!(err.contains(&addr) && err.contains("members"), "{err}");
drop(held);
bind_listener("members", &addr).await.expect("released, it binds again");
}
}

700
pool/src/sidechain.rs Normal file
View file

@ -0,0 +1,700 @@
//! The share chain of the open pool (mission item 11; docs/plans/pool.md section 10; spec 09 section 9.12).
//!
//! A share is a real block template at the share chain's target: a header whose `vote_key_hash` is the member's own
//! key, whose coinbase extra data carries the member's key reveal, its own `IGNA` payout address, the share it
//! extends (`IGNS`) and the window's payout split (`IGNP`), with a nonce whose lane hash is at or below the chain's
//! target. Shares form a chain (one parent each; the heaviest cumulative work wins; a share off the winning chain is
//! stale and pays nothing). When a share's lane hash is also at or below the block target it is a block, and its
//! coinbase, which every member checked before hashing, pays the window: the execution layer splits the producer
//! share by `IGNP` from `pool_split_activation_daa` on (`kaspa_consensus_core::evm::split_producer`). Nobody holds a
//! balance and nobody holds an operator key: the chain is computed by every member from the shares alone.
//!
//! Shape (Monero P2Pool's, SChernykh 2021, approximate from its README; the lineage is in pool.md section 10):
//! a target of `chain_share_s` seconds per share held by a retarget over the last `RETARGET_SHARES` shares, a PPLNS
//! window of `window_shares` shares that includes the share itself, uncles not yet (a fork's loser is stale; the
//! orphan rate is the row the gate measures, pool.md section 10.5).
use kaspa_consensus_core::block::Block;
use kaspa_consensus_core::evm::{miner_address_in, payout_split_extra_data, payout_split_in, share_chain_extra_data, share_chain_parent_in, PAYOUT_SPLIT_MAX_ENTRIES};
use kaspa_consensus_core::finality::KeyReveal;
use kaspa_consensus_core::igneum::ProgramClass;
use kaspa_hashes::Hash;
use kaspa_pow::igneum::{header_prehash, EpochSeeds, IgneumEngine};
use kaspa_rpc_core::RpcRawBlock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
/// Shares the retarget looks back over.
pub const RETARGET_SHARES: usize = 30;
/// A retarget step moves the target by at most this factor either way.
pub const RETARGET_CLAMP: u128 = 4;
/// The chain's target never goes above this (a saturated target makes every hash a share).
pub const MAX_TARGET: u64 = 1 << 62;
/// The retarget never moves the target more than this many doublings above the chain's first target (a chain
/// whose hashrate fell a millionfold is restarted with a new first target, not followed into a flood).
pub const MAX_EASING_DOUBLINGS: u32 = 20;
/// Shares behind in the window's ratio before the retarget is trusted at all (a window of two is noise).
pub const RETARGET_MIN_SHARES: usize = 8;
/// Heights kept below the tip before a share is pruned from memory.
pub const KEEP_DEPTH: u64 = 20_000;
/// How far behind the tip a share may extend the chain (a deeper fork is refused; the chain's finality). At ten
/// shares a second (the gate's rate) this is 200 s of chain; at the default ten seconds a share, over five hours.
pub const MAX_REORG_DEPTH: u64 = 2_000;
pub const GENESIS_PARENT: [u8; 32] = [0u8; 32];
/// The seeds of a share's epoch, on the wire and in the log, with the network's cache rule (genesis day and dataset
/// size, the day length) so a standalone verifier (`verify-share`) builds the same cache as the daemon did.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeedsWire {
pub epoch: String,
pub day: u64,
pub class: u32,
pub era: String,
pub reps: u32,
#[serde(default)]
pub genesis_day_index: u64,
#[serde(default)]
pub genesis_dataset_log2: u32,
#[serde(default)]
pub epoch_blocks: u64,
#[serde(default)]
pub epoch_lead: u64,
#[serde(default)]
pub day_ms: u64,
}
impl SeedsWire {
pub fn from_seeds(s: &EpochSeeds) -> Self {
use kaspa_consensus_core::igneum::{pow_genesis_dataset_log2, pow_genesis_day_index, pow_schedule};
let sched = pow_schedule();
Self {
epoch: s.epoch.to_string(),
day: s.day,
class: s.class.generator_version(),
era: s.era.to_string(),
reps: s.shadow_reps as u32,
genesis_day_index: pow_genesis_day_index(),
genesis_dataset_log2: pow_genesis_dataset_log2(),
epoch_blocks: sched.epoch_blocks,
epoch_lead: sched.epoch_lead,
day_ms: sched.day_ms,
}
}
/// Installs the cache rule the share names (a standalone verifier; the daemon has its node's already).
pub fn install(&self) {
use kaspa_consensus_core::igneum::{install_pow_genesis, install_pow_schedule, pow_genesis_dataset_log2, pow_genesis_day_index, pow_schedule, PowSchedule};
if self.genesis_dataset_log2 > 0 && (self.genesis_day_index, self.genesis_dataset_log2) != (pow_genesis_day_index(), pow_genesis_dataset_log2()) {
install_pow_genesis(self.genesis_day_index, self.genesis_dataset_log2);
}
if self.epoch_blocks > 0 {
let wanted = PowSchedule::clamped(self.epoch_blocks, self.epoch_lead, self.day_ms);
if wanted != pow_schedule() {
install_pow_schedule(wanted);
}
}
}
pub fn to_seeds(&self) -> Result<EpochSeeds, String> {
let epoch: Hash = self.epoch.parse().map_err(|e| format!("epoch seed: {e}"))?;
let era: Hash = self.era.parse().map_err(|e| format!("era seed: {e}"))?;
let class = ProgramClass::from_generator(self.class).ok_or_else(|| format!("program class {} unknown", self.class))?;
Ok(EpochSeeds { epoch, day: self.day, class, era, shadow_reps: self.reps as u16 })
}
}
/// One share: everything a peer needs to verify it and everything a member needs to prove it later.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Share {
/// The block hash of the header with its nonce (the share's identity)
pub hash: String,
pub parent: String,
pub height: u64,
pub address: String,
pub key_hash: String,
pub timestamp: u64,
pub daa_score: u64,
pub nonce: String,
pub lane_hash: String,
/// The chain's target this share met (the parent fixes it)
pub share_target64: String,
pub block_target64: String,
pub seeds: SeedsWire,
pub raw: RpcRawBlock,
#[serde(default)]
pub received_ms: u64,
}
impl Share {
pub fn hash32(&self) -> [u8; 32] {
parse32(&self.hash).unwrap_or(GENESIS_PARENT)
}
pub fn parent32(&self) -> [u8; 32] {
parse32(&self.parent).unwrap_or(GENESIS_PARENT)
}
pub fn address20(&self) -> [u8; 20] {
crate::protocol::parse_hex_array::<20>(&self.address).unwrap_or([0u8; 20])
}
pub fn target(&self) -> u64 {
crate::protocol::parse_hex_u64(&self.share_target64).unwrap_or(1)
}
pub fn lane(&self) -> u64 {
crate::protocol::parse_hex_u64(&self.lane_hash).unwrap_or(u64::MAX)
}
pub fn nonce_u64(&self) -> u64 {
crate::protocol::parse_hex_u64(&self.nonce).unwrap_or(0)
}
/// Expected hashes of this share: `2^64 / target`
pub fn work(&self) -> u128 {
work_of(self.target())
}
pub fn is_block(&self) -> bool {
self.lane() <= crate::protocol::parse_hex_u64(&self.block_target64).unwrap_or(0)
}
pub fn line(&self) -> String {
serde_json::to_string(self).expect("share serialises")
}
}
pub fn parse32(s: &str) -> Option<[u8; 32]> {
crate::protocol::parse_hex_array::<32>(s)
}
pub fn work_of(target: u64) -> u128 {
if target == 0 { u128::MAX >> 64 } else { ((1u128 << 64) / target as u128).max(1) }
}
/// The chain's fixed parameters: every member of one chain holds the same, or its shares mismatch on the split.
#[derive(Clone, Debug)]
pub struct ChainParams {
pub name: String,
pub share_ms: u64,
pub window: usize,
pub dev_fee_percent: u32,
pub dev_address: [u8; 20],
}
#[derive(Default)]
pub struct ShareChain {
pub params: Option<ChainParams>,
pub shares: HashMap<[u8; 32], Arc<Share>>,
/// Cumulative work up to and including the share
pub work: HashMap<[u8; 32], u128>,
pub tip: Option<[u8; 32]>,
/// The first chain target, from the first block template seen (eight times easier than a block)
pub genesis_target: Option<u64>,
pub stale: u64,
pub accepted: u64,
pub rejected: u64,
pub reorgs: u64,
}
impl ShareChain {
pub fn new(params: ChainParams) -> Self {
Self { params: Some(params), ..Default::default() }
}
fn p(&self) -> &ChainParams {
self.params.as_ref().expect("chain params")
}
pub fn get(&self, h: &[u8; 32]) -> Option<Arc<Share>> {
self.shares.get(h).cloned()
}
pub fn tip_share(&self) -> Option<Arc<Share>> {
self.tip.and_then(|t| self.get(&t))
}
pub fn height(&self) -> u64 {
self.tip_share().map(|s| s.height + 1).unwrap_or(0)
}
pub fn tip_work(&self) -> u128 {
self.tip.and_then(|t| self.work.get(&t).copied()).unwrap_or(0)
}
/// The share and up to `n - 1` of its ancestors, newest first.
pub fn ancestry(&self, from: &[u8; 32], n: usize) -> Vec<Arc<Share>> {
let mut out = Vec::with_capacity(n.min(4096));
let mut cur = *from;
while out.len() < n {
let Some(s) = self.get(&cur) else { break };
cur = s.parent32();
out.push(s);
if cur == GENESIS_PARENT {
break;
}
}
out
}
/// The chain target for a share extending `parent` (`None` or the zero hash: a genesis share). The Kaspa DAA's
/// shape: the mean target over the last `RETARGET_SHARES` shares times the window's actual span over its expected
/// span (header timestamps), clamped to a factor of `RETARGET_CLAMP` against the parent's target, never above
/// `MAX_TARGET` or the first target eased `MAX_EASING_DOUBLINGS` times, never below 1. Integer arithmetic only.
/// (The first version multiplied the last target by the window's ratio at every share, which compounds: a slow
/// start ran the target up to the saturation cap in thirty shares and every hash became a share.)
pub fn target_after(&self, parent: &[u8; 32]) -> Option<u64> {
let genesis = self.genesis_target?;
// in u128: `checked_shl` only refuses a shift of 64 or more and wraps the value otherwise (the box smoke run of
// 7 October: a 2^49 first target shifted by 20 wrapped to 0, every chain target read 0, no share ever entered the chain)
let ceiling = ((genesis as u128) << MAX_EASING_DOUBLINGS).min(MAX_TARGET as u128);
if *parent == GENESIS_PARENT {
return Some((genesis as u128).min(ceiling) as u64);
}
let chain = self.ancestry(parent, RETARGET_SHARES + 1);
let last = chain.first()?;
if chain.len() < RETARGET_MIN_SHARES {
return Some(last.target());
}
let oldest = chain.last().unwrap();
let steps = (chain.len() - 1) as u128;
let mean_target: u128 = chain.iter().map(|s| s.target() as u128).sum::<u128>() / chain.len() as u128;
let actual_ms = last.timestamp.saturating_sub(oldest.timestamp).max(1) as u128;
let expected_ms = steps * self.p().share_ms as u128;
// mean target x actual / expected: a slow window gets an easier (larger) target. The clamp is against the
// WINDOW'S MEAN, never the parent's target: a clamp against the parent compounds share by share (the 100-member
// run of 7 October on a Mac at load 113: thirty slow shares eased the target 4^30 to the ceiling, every hash a
// share, two million "parent unknown" refusals and ten diverged tips)
let next = (mean_target * actual_ms / expected_ms).clamp(mean_target / RETARGET_CLAMP, mean_target.saturating_mul(RETARGET_CLAMP));
Some(next.max(1).min(ceiling) as u64)
}
/// The window's payout split for a share by `address` at `target` extending `parent`: the last `window - 1`
/// shares ending at the parent plus the share itself, weighed by expected hashes, summed per address; the
/// software dev fee as one more entry at `dev_fee_percent` of the whole; scaled to u32 weights; descending by
/// weight then ascending by address; at most `PAYOUT_SPLIT_MAX_ENTRIES` (the smallest dropped beyond it).
pub fn window_split(&self, parent: &[u8; 32], address: [u8; 20], target: u64) -> Vec<([u8; 20], u32)> {
let p = self.p();
let mut by: HashMap<[u8; 20], u128> = HashMap::new();
*by.entry(address).or_insert(0) += work_of(target);
if *parent != GENESIS_PARENT {
for s in self.ancestry(parent, p.window.saturating_sub(1).max(1)) {
*by.entry(s.address20()).or_insert(0) += s.work();
}
}
// scaled by 2^32 before the fee and the u32 weights, so integer division loses nothing at an easy target
// (a genesis share's work is a few dozen hashes; the dev entry would round to a third of its share)
for w in by.values_mut() {
*w <<= 32;
}
let members: u128 = by.values().sum();
if p.dev_fee_percent > 0 && p.dev_fee_percent < 100 {
let dev = members * p.dev_fee_percent as u128 / (100 - p.dev_fee_percent) as u128;
*by.entry(p.dev_address).or_insert(0) += dev;
}
let total: u128 = by.values().sum::<u128>().max(1);
let mut out: Vec<([u8; 20], u32)> = by.into_iter().map(|(a, w)| (a, (w * (u32::MAX as u128) / total) as u32)).filter(|(_, w)| *w > 0).collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
out.truncate(PAYOUT_SPLIT_MAX_ENTRIES);
out
}
/// The extra data a member's template carries for `parent`: reveal, own address, the commitment, the split.
pub fn extra_data_for(&self, reveal: &KeyReveal, address: [u8; 20], parent: &[u8; 32], target: u64) -> Vec<u8> {
let mut v = reveal.to_extra_data();
v.extend_from_slice(&kaspa_consensus_core::evm::miner_address_extra_data(&address));
v.extend_from_slice(&share_chain_extra_data(parent));
if let Some(split) = payout_split_extra_data(&self.window_split(parent, address, target)) {
v.extend_from_slice(&split);
}
v
}
/// Everything about a share that the chain alone decides (the PoW is the caller's, it needs the engine):
/// its parent, height, target, the coinbase's commitment, address, reveal and split, and the block hash.
pub fn check_structure(&self, s: &Share) -> Result<(), String> {
let parent = s.parent32();
let (parent_height, parent_known) = if parent == GENESIS_PARENT {
(None, true)
} else {
match self.get(&parent) {
Some(p) => (Some(p.height), true),
None => (None, false),
}
};
if !parent_known {
return Err(format!("parent {} unknown", &s.parent[..16]));
}
let height = parent_height.map(|h| h + 1).unwrap_or(0);
if s.height != height {
return Err(format!("height {} but the parent is at {}", s.height, height.saturating_sub(1)));
}
if let Some(tip) = self.tip_share()
&& tip.height.saturating_sub(height) > MAX_REORG_DEPTH
{
return Err(format!("extends height {height} while the tip is at {}: deeper than {MAX_REORG_DEPTH}", tip.height));
}
let target = self.target_after(&parent).ok_or("no chain target yet (no template seen)")?;
if s.target() != target {
return Err(format!("share target {:016x} but the chain's is {:016x} after this parent", s.target(), target));
}
let block: Block = s.raw.clone().try_into().map_err(|e| format!("block convert: {e}"))?;
if block.hash().as_bytes() != s.hash32() {
return Err("hash is not the block's".into());
}
if block.header.nonce != s.nonce_u64() {
return Err("nonce is not the header's".into());
}
let root = kaspa_consensus_core::merkle::calc_block_hash_merkle_root(block.transactions.iter(), block.evm_transactions.iter());
if root != block.header.hash_merkle_root {
return Err("hash_merkle_root does not commit to the body".into());
}
let cb = block.transactions.first().ok_or("no coinbase")?;
let reveal = KeyReveal::find_in(&cb.payload).ok_or("coinbase has no key reveal")?;
if reveal.key_hash() != block.header.vote_key_hash {
return Err("the header's vote key is not the revealed key".into());
}
if reveal.key_hash().to_string() != s.key_hash {
return Err("key_hash is not the header's".into());
}
let address = miner_address_in(&cb.payload).ok_or("coinbase has no IGNA address")?;
if address != s.address20() {
return Err("address is not the coinbase's IGNA".into());
}
if share_chain_parent_in(&cb.payload) != Some(parent) {
return Err("the coinbase commits to another parent".into());
}
let expected = self.window_split(&parent, address, target);
let carried = payout_split_in(&cb.payload).ok_or("coinbase has no payout split")?;
if carried != expected {
return Err(format!("the split differs from the window's ({} entries carried, {} expected)", carried.len(), expected.len()));
}
if block.header.timestamp != s.timestamp || block.header.daa_score != s.daa_score {
return Err("timestamp or DAA score is not the header's".into());
}
if let Some(h) = parent_height.and_then(|_| self.get(&parent))
&& s.timestamp + 600_000 < h.timestamp
{
return Err("timestamp more than 600 s before the parent's".into());
}
Ok(())
}
/// Inserts a checked share; returns whether it became the tip (a heavier chain; equal work keeps the tip).
pub fn insert(&mut self, s: Arc<Share>) -> bool {
let h = s.hash32();
if self.shares.contains_key(&h) {
return false;
}
let parent_work = if s.parent32() == GENESIS_PARENT { 0 } else { self.work.get(&s.parent32()).copied().unwrap_or(0) };
let w = parent_work + s.work();
let was_tip = self.tip;
self.shares.insert(h, s.clone());
self.work.insert(h, w);
self.accepted += 1;
let better = match self.tip {
None => true,
Some(t) => w > self.work.get(&t).copied().unwrap_or(0),
};
if better {
if let Some(old) = was_tip
&& old != s.parent32()
{
self.reorgs += 1;
}
self.tip = Some(h);
self.prune();
} else {
self.stale += 1;
}
better
}
fn prune(&mut self) {
let Some(tip) = self.tip_share() else { return };
if tip.height <= KEEP_DEPTH || self.shares.len() % 1000 != 0 {
return;
}
let floor = tip.height - KEEP_DEPTH;
let gone: Vec<[u8; 32]> = self.shares.iter().filter(|(_, s)| s.height < floor).map(|(h, _)| *h).collect();
for h in gone {
self.shares.remove(&h);
self.work.remove(&h);
}
}
/// The shares from height `from` up the tip's ancestry, ascending, at most `count`.
pub fn range(&self, from: u64, count: usize) -> Vec<Arc<Share>> {
let Some(tip) = self.tip else { return Vec::new() };
let mut v: Vec<Arc<Share>> = self.ancestry(&tip, usize::MAX).into_iter().filter(|s| s.height >= from).collect();
v.reverse();
v.truncate(count);
v
}
/// The current window's split as fractions, for the API.
pub fn window_fractions(&self) -> Vec<(String, f64)> {
let Some(tip) = self.tip else { return Vec::new() };
let Some(t) = self.tip_share() else { return Vec::new() };
let split = self.window_split(&t.parent32(), t.address20(), t.target());
let _ = tip;
let total: u64 = split.iter().map(|(_, w)| *w as u64).sum::<u64>().max(1);
split.into_iter().map(|(a, w)| (format!("0x{}", hex::encode(a)), w as f64 / total as f64)).collect()
}
}
/// The PoW of a share: the lane hash under the share's seeds equals the claim and meets the share's target.
pub fn check_pow(engine: &IgneumEngine, s: &Share) -> Result<(), String> {
let seeds = s.seeds.to_seeds()?;
let block: Block = s.raw.clone().try_into().map_err(|e| format!("block convert: {e}"))?;
let prehash = header_prehash(&block.header);
let epoch = engine.epoch_for(&seeds);
let lane = epoch.hash_bound(&prehash, block.header.nonce);
if lane != s.lane() {
return Err(format!("lane hash {lane:016x} is not the claimed {}", s.lane_hash));
}
if lane > s.target() {
return Err(format!("lane hash {lane:016x} is above the share target {}", s.share_target64));
}
Ok(())
}
/// `igneum-pool verify-share <share json> [--block <raw block json>]`: the drop proof from a member's own log.
/// Re-hashes the share (its PoW stands on its own: the seeds, the header and the nonce are in the line), reads
/// its coinbase, and, given a block, says whether that block's split pays the share's address. Exit 0: the share
/// verifies (and the block pays it); 1: the share does not verify; 3: the block omits the address (DROPPED).
pub fn verify_share_cli(args: &[String]) -> i32 {
let Some(line) = args.first() else {
eprintln!("usage: igneum-pool verify-share '<share json line>' [--block '<raw block json>']");
return 2;
};
let text = if std::path::Path::new(line).is_file() { std::fs::read_to_string(line).unwrap_or_default() } else { line.clone() };
let text = text.trim().trim_start_matches("SHARE ").to_string();
let s: Share = match serde_json::from_str(&text) {
Ok(s) => s,
Err(e) => {
eprintln!("not a share line: {e}");
return 2;
}
};
s.seeds.install();
let engine = IgneumEngine::new();
match check_pow(&engine, &s) {
Ok(()) => println!("SHARE OK {} height {} by {} lane {} <= target {} (work {} hashes; cache 2^{} words of day {})", &s.hash[..16], s.height, s.address, s.lane_hash, s.share_target64, s.work(), s.seeds.genesis_dataset_log2, s.seeds.day),
Err(e) => {
println!("SHARE INVALID {}: {e}", &s.hash[..16]);
return 1;
}
}
let Some(i) = args.iter().position(|a| a == "--block") else { return 0 };
let Some(b) = args.get(i + 1) else { return 2 };
let btext = if std::path::Path::new(b).is_file() { std::fs::read_to_string(b).unwrap_or_default() } else { b.clone() };
let raw: RpcRawBlock = match serde_json::from_str(btext.trim()) {
Ok(r) => r,
Err(e) => {
eprintln!("not a raw block: {e}");
return 2;
}
};
let Some(cb) = raw.transactions.first() else {
println!("BLOCK has no coinbase");
return 2;
};
let split = payout_split_in(&cb.payload).unwrap_or_default();
let total: u64 = split.iter().map(|(_, w)| *w as u64).sum::<u64>().max(1);
match split.iter().find(|(a, _)| *a == s.address20()) {
Some((_, w)) => {
println!("BLOCK PAYS {} weight {w} of {total} ({:.4}%) at parent {}", s.address, *w as f64 / total as f64 * 100.0, share_chain_parent_in(&cb.payload).map(|p| hex::encode(&p[..8])).unwrap_or_else(|| "none".into()));
0
}
None => {
println!("BLOCK DROPS {}: its split of {} entries omits the share's address (commitment parent {})", s.address, split.len(), share_chain_parent_in(&cb.payload).map(|p| hex::encode(&p[..8])).unwrap_or_else(|| "none".into()));
3
}
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use kaspa_consensus_core::header::Header;
use kaspa_consensus_core::subnets::SUBNETWORK_ID_COINBASE;
use kaspa_consensus_core::tx::Transaction;
use kaspa_rpc_core::{RpcRawHeader, RpcTransaction};
pub fn params() -> ChainParams {
ChainParams { name: "test".into(), share_ms: 10_000, window: 8, dev_fee_percent: 1, dev_address: [0xdd; 20] }
}
/// A share on `chain` extending `parent` by `address` with the given key, at the chain's target, with a header
/// whose nonce is found by brute force so its lane hash meets the target (a v2 epoch, small cache, test speed).
pub fn make_share(chain: &ShareChain, engine: &IgneumEngine, parent: [u8; 32], label: &str, address: [u8; 20], timestamp: u64, daa: u64) -> Share {
let key = kaspa_consensus_core::finality::VoteSecretKey::from_label(label);
let reveal = key.key_reveal();
let target = chain.target_after(&parent).expect("target");
let extra = chain.extra_data_for(&reveal, address, &parent, target);
// a coinbase payload: blue score || subsidy || script version || script len || script || extra
let mut payload = Vec::new();
payload.extend_from_slice(&0u64.to_le_bytes());
payload.extend_from_slice(&0u64.to_le_bytes());
payload.extend_from_slice(&0u16.to_le_bytes());
payload.push(0);
payload.extend_from_slice(&extra);
let cb = Transaction::new(0, vec![], vec![], 0, SUBNETWORK_ID_COINBASE, 0, payload);
let root = kaspa_consensus_core::merkle::calc_block_hash_merkle_root(std::iter::once(&cb), std::iter::empty());
let seeds = EpochSeeds::v2(Hash::from_bytes([5u8; 32]), 0);
let epoch = engine.epoch_for(&seeds);
let mut header = Header::new_finalized(1, vec![vec![Hash::from_bytes([1u8; 32])]].try_into().unwrap(), root, Hash::default(), Hash::default(), timestamp, 0x1e400000, 0, daa, 0.into(), 0, Hash::default(), reveal.key_hash());
let prehash = header_prehash(&header);
let mut nonce = 0u64;
let lane = loop {
let h = epoch.hash_bound(&prehash, nonce);
if h <= target {
break h;
}
nonce += 1;
};
header.nonce = nonce;
header.finalize();
let raw_header = RpcRawHeader {
version: header.version,
parents_by_level: header.parents_by_level.clone().into(),
hash_merkle_root: header.hash_merkle_root,
accepted_id_merkle_root: header.accepted_id_merkle_root,
utxo_commitment: header.utxo_commitment,
timestamp: header.timestamp,
bits: header.bits,
nonce: header.nonce,
daa_score: header.daa_score,
blue_work: header.blue_work,
blue_score: header.blue_score,
pruning_point: header.pruning_point,
vote_key_hash: header.vote_key_hash,
};
let raw = RpcRawBlock { header: raw_header, transactions: vec![RpcTransaction::from(&cb)], evm_transactions: vec![] };
let height = if parent == GENESIS_PARENT { 0 } else { chain.get(&parent).unwrap().height + 1 };
Share {
hash: header.hash.to_string(),
parent: hex::encode(parent),
height,
address: format!("0x{}", hex::encode(address)),
key_hash: reveal.key_hash().to_string(),
timestamp,
daa_score: daa,
nonce: format!("{nonce:016x}"),
lane_hash: format!("{lane:016x}"),
share_target64: format!("{target:016x}"),
block_target64: format!("{:016x}", kaspa_pow::igneum::target64(header.bits) >> 20),
seeds: SeedsWire::from_seeds(&seeds),
raw,
received_ms: 0,
}
}
/// A chain of shares by three members: every share passes the structure check and the PoW check, the window's
/// split is the members' work plus the dev entry and sums to one, the retarget moves toward the share interval,
/// a withheld share is in nobody's window, and a share off the heavier branch is stale.
#[test]
fn shares_chain_split_and_fork_choice() {
let engine = IgneumEngine::new();
let mut chain = ShareChain::new(params());
chain.genesis_target = Some(u64::MAX >> 6);
let a = [0xaa; 20];
let b = [0xbb; 20];
let mut parent = GENESIS_PARENT;
let mut t = 1_000_000u64;
for i in 0..6u64 {
let who = if i % 2 == 0 { ("ma", a) } else { ("mb", b) };
let s = make_share(&chain, &engine, parent, who.0, who.1, t, i);
chain.check_structure(&s).unwrap_or_else(|e| panic!("share {i}: {e}"));
check_pow(&engine, &s).unwrap();
let s = Arc::new(s);
assert!(chain.insert(s.clone()), "each extends the tip");
parent = s.hash32();
t += 10_000;
}
assert_eq!(chain.height(), 6);
let tip = chain.tip_share().unwrap();
let split = chain.window_split(&tip.parent32(), tip.address20(), tip.target());
assert_eq!(split.len(), 3, "a, b and the dev entry");
assert!(split.iter().any(|(x, _)| *x == [0xdd; 20]));
let total: u64 = split.iter().map(|(_, w)| *w as u64).sum();
assert!(total > u32::MAX as u64 - 8 && total <= u32::MAX as u64, "weights scale to 2^32: {total}");
let dev = split.iter().find(|(x, _)| *x == [0xdd; 20]).unwrap().1 as f64 / total as f64;
assert!((dev - 0.01).abs() < 1e-6, "the dev entry is 1 percent of the whole: {dev}");
// a withheld share (never inserted) is in nobody's window; a share whose split names it is refused
let withheld = make_share(&chain, &engine, parent, "mw", [0xcc; 20], t, 6);
let after = chain.window_split(&parent, a, tip.target());
assert!(!after.iter().any(|(x, _)| *x == [0xcc; 20]));
let _ = withheld;
// the retarget: six shares ten seconds apart keep the target where it is (under the minimum window it is the
// parent's); a window of shares arriving ten times too slowly eases by four at most, and no further on the
// next share (the clamp is against the window's mean, not compounded)
let t0 = chain.genesis_target.unwrap();
let tn = chain.target_after(&parent).unwrap();
assert_eq!(tn, t0);
{
let mut slow = ShareChain::new(params());
slow.genesis_target = Some(1 << 58);
let mut p = GENESIS_PARENT;
let mut ts = 1_000_000u64;
for i in 0..RETARGET_MIN_SHARES as u64 + 4 {
let s = Arc::new(make_share(&slow, &engine, p, "ma", a, ts, i));
slow.check_structure(&s).unwrap();
slow.insert(s.clone());
p = s.hash32();
ts += 100_000; // ten times the 10-second interval
}
let eased = slow.target_after(&p).unwrap();
assert!(eased > (1u64 << 58) * 2 && eased <= (1u64 << 58) * 16, "slow shares ease the target, within bounds: {eased:x}");
// no compounding: no share's target is more than four times its parent's
let chain_now = slow.ancestry(&p, 64);
for w in chain_now.windows(2) {
assert!(w[0].target() <= w[1].target().saturating_mul(4), "a share eased more than four times its parent: {:x} after {:x}", w[0].target(), w[1].target());
}
}
// a fork: two shares on the same parent; the second is stale, the tip stays
let s1 = Arc::new(make_share(&chain, &engine, parent, "ma", a, t, 6));
let s2 = Arc::new(make_share(&chain, &engine, parent, "mb", b, t + 1, 6));
assert!(chain.insert(s1.clone()));
assert!(!chain.insert(s2.clone()), "equal work: the first stays the tip");
assert_eq!(chain.stale, 1);
// the loser's branch grows by one: it becomes the heavier chain and the tip moves (a reorg)
let s3 = Arc::new(make_share(&chain, &engine, s2.hash32(), "mb", b, t + 2, 7));
assert!(chain.insert(s3.clone()));
assert_eq!(chain.tip, Some(s3.hash32()));
assert_eq!(chain.reorgs, 1);
assert!(chain.range(0, 100).iter().all(|s| s.hash32() != s1.hash32()), "the stale share is off the winning chain");
// a wrong split is refused, a wrong parent is refused, a wrong target is refused
let mut bad = make_share(&chain, &engine, s3.hash32(), "ma", a, t + 3, 8);
bad.parent = hex::encode([9u8; 32]);
assert!(chain.check_structure(&bad).unwrap_err().contains("unknown"));
let mut bad = make_share(&chain, &engine, s3.hash32(), "ma", a, t + 3, 8);
bad.share_target64 = format!("{:016x}", bad.target() / 2);
assert!(chain.check_structure(&bad).unwrap_err().contains("share target"));
let mut bad = make_share(&chain, &engine, s3.hash32(), "ma", a, t + 3, 8);
bad.lane_hash = format!("{:016x}", bad.lane() ^ 1);
assert!(check_pow(&engine, &bad).unwrap_err().contains("not the claimed"));
// the wire form round-trips
let line = s3.line();
let back: Share = serde_json::from_str(&line).unwrap();
assert_eq!(back.hash, s3.hash);
assert_eq!(back.raw.header.nonce, s3.raw.header.nonce);
assert_eq!(back.seeds.to_seeds().unwrap(), s3.seeds.to_seeds().unwrap());
}
/// The split encoding is byte-exact between members: the same window gives the same bytes whatever the order
/// the shares were seen in, and the dev entry is dropped when the fee is 0.
#[test]
fn the_split_is_deterministic_and_bounded() {
let mut p = params();
p.dev_fee_percent = 0;
let mut chain = ShareChain::new(p);
chain.genesis_target = Some(1 << 40);
let split = chain.window_split(&GENESIS_PARENT, [1u8; 20], 1 << 40);
assert_eq!(split, vec![([1u8; 20], u32::MAX)]);
let bytes = payout_split_extra_data(&split).unwrap();
assert_eq!(payout_split_in(&bytes), Some(split));
assert!(chain.target_after(&GENESIS_PARENT).unwrap() <= MAX_TARGET);
// a first target whose ceiling overflows u64 keeps the target (the wrap of 7 October)
let mut big = ShareChain::new(params());
big.genesis_target = Some(1 << 49);
assert_eq!(big.target_after(&GENESIS_PARENT), Some(1 << 49));
big.genesis_target = Some(MAX_TARGET);
assert_eq!(big.target_after(&GENESIS_PARENT), Some(MAX_TARGET));
assert_eq!(work_of(1 << 63), 2);
assert_eq!(work_of(0), u128::MAX >> 64);
}
}

View file

@ -97,11 +97,19 @@ pub struct State {
pub blocks_orphaned: u64,
pub pool_fee_wei: u128,
pub submit_errors: u64,
#[serde(skip)]
/// Q70 (7 October 2026): the hashrate samples and the check costs are persisted with the ledger, so a restart
/// keeps the rate tiles and the luck; the loss window is one snapshot interval (15 s by default)
#[serde(default)]
pub samples: VecDeque<Sample>,
/// Verification cost of the last 20,000 shares, milliseconds
#[serde(skip)]
#[serde(default)]
pub check_ms: VecDeque<f64>,
/// Q72: hourly buckets per address, kept 7 days: (hour start ms, hashes, shares)
#[serde(default)]
pub history: HashMap<String, VecDeque<(u64, f64, u64)>>,
/// Q72: addresses already alerted for silence (cleared when they send again)
#[serde(default)]
pub alerted: HashMap<String, u64>,
#[serde(skip)]
pub dirty: bool,
}
@ -126,6 +134,8 @@ impl State {
submit_errors: 0,
samples: VecDeque::new(),
check_ms: VecDeque::new(),
history: HashMap::new(),
alerted: HashMap::new(),
dirty: false,
}
}
@ -186,9 +196,55 @@ impl State {
let m = self.miners.get_mut(address).unwrap();
m.accepted += 1;
m.last_share_ms = now;
// Q72: the hourly bucket
let hour = now - now % 3_600_000;
let h = self.history.entry(address.to_string()).or_default();
match h.back_mut() {
Some(b) if b.0 == hour => {
b.1 += hashes;
b.2 += 1;
}
_ => h.push_back((hour, hashes, 1)),
}
while h.front().is_some_and(|b| b.0 + 7 * 86_400_000 < now) {
h.pop_front();
}
self.alerted.remove(address);
self.dirty = true;
}
/// A block with its payees given (the open pool: the coinbase's own split), never the PPLNS snapshot.
pub fn block_found_with(&mut self, rec: BlockRec) {
let now = unix_ms();
self.blocks_found += 1;
let w = self.miner(&rec.finder.clone(), &rec.worker.clone(), now);
w.blocks += 1;
self.miners.get_mut(&rec.finder).unwrap().blocks += 1;
self.blocks.push(rec);
while self.blocks.len() > 5000 {
self.blocks.remove(0);
}
self.dirty = true;
}
/// Q72: addresses with shares before but none for ten minutes, not yet alerted; marks them alerted.
pub fn newly_silent(&mut self) -> Vec<(String, u64)> {
let now = unix_ms();
let mut out = Vec::new();
for (a, m) in &self.miners {
if m.last_share_ms > 0 && now.saturating_sub(m.last_share_ms) > 600_000 && !self.alerted.contains_key(a) {
out.push((a.clone(), m.last_share_ms));
}
}
for (a, _) in &out {
self.alerted.insert(a.clone(), now);
}
if !out.is_empty() {
self.dirty = true;
}
out
}
pub fn refuse_share(&mut self, address: &str, worker: &str, stale: bool, cost_ms: Option<f64>) {
let now = unix_ms();
if let Some(c) = cost_ms {
@ -229,13 +285,26 @@ impl State {
self.dirty = true;
}
/// Credits a confirmed block to its payees (the PPLNS snapshot of the moment it was found).
pub fn confirm_block(&mut self, hash: &str, fee_percent: f64) -> Option<(u128, Vec<(String, u128)>)> {
/// Credits a confirmed block to its payees (the PPLNS snapshot of the moment it was found). The open pool
/// (`coinbase_paid`): the block's own coinbase paid the split, so the parts are recorded as payments with the
/// block hash as the transaction and no balance moves.
pub fn confirm_block(&mut self, hash: &str, fee_percent: f64, coinbase_paid: bool) -> Option<(u128, Vec<(String, u128)>)> {
let idx = self.blocks.iter().position(|b| b.hash == hash && b.status == "pending")?;
let reward = self.blocks[idx].reward_wei;
let (parts, kept) = crate::pplns::distribute(reward, fee_percent, &self.blocks[idx].payees);
for (a, wei) in &parts {
*self.balances.entry(a.clone()).or_insert(0) += wei;
if coinbase_paid {
let now = unix_ms();
for (a, wei) in &parts {
*self.paid.entry(a.clone()).or_insert(0) += wei;
self.payments.push(PaymentRec { at_ms: now, address: a.clone(), amount_wei: *wei, tx_hash: Some(hash.to_string()), status: "coinbase".into(), dry_run: false, nonce: None, note: "paid by the block's own coinbase (open pool)".into() });
}
while self.payments.len() > 20_000 {
self.payments.remove(0);
}
} else {
for (a, wei) in &parts {
*self.balances.entry(a.clone()).or_insert(0) += wei;
}
}
self.pool_fee_wei += kept;
self.blocks_confirmed += 1;
@ -285,6 +354,11 @@ impl State {
(found, confirmed, orphan)
}
/// Q72: the hourly history of an address, oldest first: (hour start ms, hashes per second, shares)
pub fn history_of(&self, address: &str) -> Vec<(u64, f64, u64)> {
self.history.get(address).map(|h| h.iter().map(|(t, hashes, n)| (*t, hashes / 3600.0, *n)).collect()).unwrap_or_default()
}
pub fn paid_in(&self, secs: u64) -> u128 {
let cutoff = unix_ms().saturating_sub(secs * 1000);
self.payments.iter().filter(|p| p.at_ms >= cutoff && !p.dry_run && p.status != "failed").map(|p| p.amount_wei).sum()
@ -302,3 +376,26 @@ impl State {
(n, mean, v[n / 2], v[(n * 99 / 100).min(n - 1)], v[n - 1])
}
}
/// Q72: the opt-in silence alert. Every minute, every address that sent shares before and none for ten minutes is
/// POSTed once to `--alert-webhook` as JSON (`{"pool", "address", "last_share_ms", "silent_s"}`), and again only
/// after it has sent a share in between. Without the flag the silence is only in the API (`online` false).
pub async fn alert_loop(pool: std::sync::Arc<crate::pool::Pool>) {
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let silent = pool.state.lock().unwrap().newly_silent();
let Some(url) = pool.cfg.alert_webhook.clone() else { continue };
for (address, last) in silent {
let body = serde_json::json!({"pool": pool.cfg.name, "address": address, "last_share_ms": last, "silent_s": unix_ms().saturating_sub(last) / 1000});
println!("{} ALERT {address}: no share for {} s; webhook {url}", unix_ms(), unix_ms().saturating_sub(last) / 1000);
match crate::payout::EvmRpc::new(&url) {
Ok(rpc) => {
if let Err(e) = rpc.post_json(&body).await {
eprintln!("{} alert webhook: {e}", unix_ms());
}
}
Err(e) => eprintln!("{} --alert-webhook: {e} (http:// URLs only)", unix_ms()),
}
}
}
}

184
pool/src/tls.rs Normal file
View file

@ -0,0 +1,184 @@
//! TLS 1.3 on the member port (spec 09 section 9.3; polish row Q67; O-9.7 closed here, 7 October 2026).
//!
//! The pool serves `--tls-cert <pem> --tls-key <pem>` (a chain from a CA the members' systems trust, the public
//! pool's shape), or `--tls-self-signed` (a certificate made on first start into the data directory, whose PIN the
//! pool prints at start and shows on its page; a member passes it as `--pool-pin`). Without either the member port
//! is plain TCP, which `--network testnet|mainnet` refuses unless `--allow-plain` says so.
//!
//! The `authorize` binding (O-9.7, the exact bytes): both sides export 32 bytes from the TLS session with label
//! `EXPORTER-igneum-pool-binding` and the chain id's 8 little-endian bytes as the context; the member signs
//! `igneum-pool-binding-v1/ || chain id LE || exporter` under its vote key with the binding tag (never the vote or the
//! PoP tag), and the pool verifies it against the member's public key. A replayed `authorize` on another connection
//! carries another exporter and is refused with `bye`. Over plain TCP there is no exporter and the field is ignored.
use kaspa_consensus_core::finality::{BINDING_EXPORTER_LABEL, BINDING_EXPORTER_LEN};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::path::Path;
use std::sync::Arc;
/// The pin of a certificate: `BLAKE2b("igneum-pool-cert-pin-v1" || DER)`, as the miner's `--pool-pin` checks it.
pub fn cert_pin(der: &[u8]) -> [u8; 32] {
use kaspa_hashes::HasherBase;
let mut h = kaspa_hashes::BlockHash::new();
h.update(b"igneum-pool-cert-pin-v1").update(der);
h.finalize().as_bytes()
}
pub struct Tls {
pub acceptor: tokio_rustls::TlsAcceptor,
/// The leaf certificate's pin, hex
pub pin: String,
pub self_signed: bool,
}
/// Reads a PEM chain and key, or makes a self-signed pair under `data_dir` on first use.
pub fn load(cert: Option<&Path>, key: Option<&Path>, self_signed_dir: Option<&Path>, name: &str) -> Result<Tls, String> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let (chain, key, self_signed): (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>, bool) = match (cert, key, self_signed_dir) {
(Some(c), Some(k), _) => {
use rustls::pki_types::pem::PemObject;
let chain: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(c).map_err(|e| format!("--tls-cert {}: {e}", c.display()))?.collect::<Result<_, _>>().map_err(|e| format!("--tls-cert {}: {e}", c.display()))?;
if chain.is_empty() {
return Err(format!("--tls-cert {}: no certificate in the file", c.display()));
}
let key = PrivateKeyDer::from_pem_file(k).map_err(|e| format!("--tls-key {}: {e}", k.display()))?;
(chain, key, false)
}
(None, None, Some(dir)) => {
let (c, k) = self_signed_pair(dir, name)?;
(vec![c], k, true)
}
_ => return Err("TLS needs --tls-cert with --tls-key, or --tls-self-signed".into()),
};
let pin = hex::encode(cert_pin(chain[0].as_ref()));
let config = rustls::ServerConfig::builder_with_provider(provider)
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(|e| e.to_string())?
.with_no_client_auth()
.with_single_cert(chain, key)
.map_err(|e| format!("TLS certificate and key: {e}"))?;
Ok(Tls { acceptor: tokio_rustls::TlsAcceptor::from(Arc::new(config)), pin, self_signed })
}
/// `<dir>/tls-cert.pem` and `<dir>/tls-key.pem`: made once (ECDSA P-256, ten years, the pool's name as the subject),
/// read back afterwards so the pin survives restarts.
fn self_signed_pair(dir: &Path, name: &str) -> Result<(CertificateDer<'static>, PrivateKeyDer<'static>), String> {
use rustls::pki_types::pem::PemObject;
let cert_path = dir.join("tls-cert.pem");
let key_path = dir.join("tls-key.pem");
if !(cert_path.is_file() && key_path.is_file()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let mut params = rcgen::CertificateParams::new(vec!["igneum-pool".to_string()]).map_err(|e| e.to_string())?;
params.distinguished_name.push(rcgen::DnType::CommonName, name);
let key_pair = rcgen::KeyPair::generate().map_err(|e| e.to_string())?;
let cert = params.self_signed(&key_pair).map_err(|e| e.to_string())?;
std::fs::write(&cert_path, cert.pem()).map_err(|e| format!("{}: {e}", cert_path.display()))?;
std::fs::write(&key_path, key_pair.serialize_pem()).map_err(|e| format!("{}: {e}", key_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600));
}
eprintln!("tls: self-signed certificate made at {} (key {}, mode 0600)", cert_path.display(), key_path.display());
}
let cert = CertificateDer::from_pem_file(&cert_path).map_err(|e| format!("{}: {e}", cert_path.display()))?;
let key = PrivateKeyDer::from_pem_file(&key_path).map_err(|e| format!("{}: {e}", key_path.display()))?;
Ok((cert, key))
}
/// The binding exporter of a server-side TLS session (the same 32 bytes the member exports).
pub fn exporter(conn: &rustls::ServerConnection, chain_id: u64) -> Result<[u8; BINDING_EXPORTER_LEN], String> {
let mut out = [0u8; BINDING_EXPORTER_LEN];
conn.export_keying_material(&mut out[..], BINDING_EXPORTER_LABEL, Some(&chain_id.to_le_bytes())).map_err(|e| format!("TLS exporter: {e}"))?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use kaspa_consensus_core::finality::{verify_binding, VoteSecretKey};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
/// A self-signed pair is made once and read back with the same pin; a member that pins it completes a TLS 1.3
/// handshake, both sides export the same 32 bytes, the member's binding verifies on this connection, and the same
/// binding presented on a second connection is refused (O-9.7's gate).
#[tokio::test]
async fn self_signed_tls_pins_exports_and_binds_one_connection() {
let dir = std::env::temp_dir().join(format!("igneum-pool-tls-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let a = load(None, None, Some(&dir), "test pool").unwrap();
let b = load(None, None, Some(&dir), "test pool").unwrap();
assert_eq!(a.pin, b.pin, "the pair is read back, the pin survives a restart");
assert!(a.self_signed);
let pin: [u8; 32] = hex::decode(&a.pin).unwrap().try_into().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let acceptor = a.acceptor.clone();
let server = tokio::spawn(async move {
let mut exporters = Vec::new();
for _ in 0..2 {
let (sock, _) = listener.accept().await.unwrap();
let mut tls = acceptor.accept(sock).await.unwrap();
let e = exporter(tls.get_ref().1, 4463).unwrap();
let mut line = String::new();
BufReader::new(&mut tls).read_line(&mut line).await.unwrap();
exporters.push((e, line.trim().to_string()));
}
exporters
});
// the member side: a pin verifier like the miner's
#[derive(Debug)]
struct Pin([u8; 32], Arc<rustls::crypto::CryptoProvider>);
impl rustls::client::danger::ServerCertVerifier for Pin {
fn verify_server_cert(&self, ee: &CertificateDer<'_>, _: &[CertificateDer<'_>], _: &rustls::pki_types::ServerName<'_>, _: &[u8], _: rustls::pki_types::UnixTime) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
if cert_pin(ee.as_ref()) == self.0 { Ok(rustls::client::danger::ServerCertVerified::assertion()) } else { Err(rustls::Error::General("pin".into())) }
}
fn verify_tls12_signature(&self, m: &[u8], c: &CertificateDer<'_>, d: &rustls::DigitallySignedStruct) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(m, c, d, &self.1.signature_verification_algorithms)
}
fn verify_tls13_signature(&self, m: &[u8], c: &CertificateDer<'_>, d: &rustls::DigitallySignedStruct) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(m, c, d, &self.1.signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.1.signature_verification_algorithms.supported_schemes()
}
}
let provider = Arc::new(rustls::crypto::ring::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider.clone()).with_protocol_versions(&[&rustls::version::TLS13]).unwrap().dangerous().with_custom_certificate_verifier(Arc::new(Pin(pin, provider.clone()))).with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(cfg));
let key = VoteSecretKey::from_label("member-tls-test");
let mut client_exporters = Vec::new();
let mut sigs = Vec::new();
for _ in 0..2 {
let sock = tokio::net::TcpStream::connect(addr).await.unwrap();
let mut tls = connector.connect(rustls::pki_types::ServerName::try_from("igneum-pool").unwrap(), sock).await.unwrap();
let mut e = [0u8; BINDING_EXPORTER_LEN];
tls.get_ref().1.export_keying_material(&mut e[..], BINDING_EXPORTER_LABEL, Some(&4463u64.to_le_bytes())).unwrap();
let sig = key.sign_binding(4463, &e);
tls.write_all(format!("{}\n", hex::encode(sig)).as_bytes()).await.unwrap();
tls.flush().await.unwrap();
client_exporters.push(e);
sigs.push(sig);
tls.shutdown().await.ok();
}
let server_side = server.await.unwrap();
assert_eq!(server_side[0].0, client_exporters[0], "both sides export the same bytes");
assert_ne!(server_side[0].0, server_side[1].0, "two connections, two exporters");
assert!(verify_binding(&key.public_key(), 4463, &server_side[0].0, &sigs[0]));
assert!(!verify_binding(&key.public_key(), 4463, &server_side[1].0, &sigs[0]), "a replayed authorize on a second connection is refused");
assert_eq!(server_side[0].1, hex::encode(sigs[0]));
// a wrong pin never completes the handshake
let cfg = rustls::ClientConfig::builder_with_provider(provider.clone()).with_protocol_versions(&[&rustls::version::TLS13]).unwrap().dangerous().with_custom_certificate_verifier(Arc::new(Pin([0u8; 32], provider))).with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(cfg));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let acceptor = b.acceptor.clone();
tokio::spawn(async move {
let (sock, _) = listener.accept().await.unwrap();
let _ = acceptor.accept(sock).await;
});
let sock = tokio::net::TcpStream::connect(addr).await.unwrap();
assert!(connector.connect(rustls::pki_types::ServerName::try_from("igneum-pool").unwrap(), sock).await.is_err());
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -79,8 +79,11 @@ impl Vardiff {
return None;
}
if n == 0 {
// nothing yet after an interval: wait up to three intervals, then one easier step
if (now_s - self.started_s) < 3.0 * self.interval_s {
// nothing yet after an interval: wait up to three intervals, then one easier step. The sized
// correction stays owed (first_done stays false): 7 October 2026, pm-1's worker compiled its pack for
// 40 s, the idle easing consumed the first correction, and the measured 190 shares a second then
// walked down one step per 30 s for 5.5 minutes, saturating the verifier
if now_s - self.last_change_s.max(self.started_s) < 3.0 * self.interval_s {
return None;
}
self.shift = (self.shift + 1).min(cap);
@ -92,8 +95,8 @@ impl Vardiff {
} else if per_interval < 0.66 {
self.shift = (self.shift + steps).min(cap);
}
self.first_done = true;
}
self.first_done = true;
} else {
if since_change < self.min_change_s {
return None;
@ -226,4 +229,23 @@ mod tests {
}
assert_eq!(v.retarget(t, 1 << 20), Some(2), "floor at min_shift");
}
/// 7 October 2026, pm-1: a member idle for four intervals (its worker compiling) gets eased one step, and the
/// first measured rate must still size the jump (190 shares a second at shift 11 is 11 steps away from one per
/// 10 s; the sized step takes 8 at once), not walk down one step per 30 s.
#[test]
fn an_idle_easing_does_not_consume_the_sized_first_correction() {
let t64 = 1u64 << 36;
let mut v = Vardiff::new(10, 0, 20, 10.0, 0.0);
assert_eq!(v.retarget(31.0, t64), Some(11), "idle three intervals: one easier step");
assert_eq!(v.retarget(40.0, t64), None, "still idle, nothing within the next three intervals");
// the worker is ready: 190 shares a second for 10 s
let mut t = 40.0;
for _ in 0..1900 {
t += 10.0 / 1900.0;
v.on_share(t);
}
let s = v.retarget(t + 0.1, t64).expect("the first measured rate sizes the jump");
assert!(s <= 3, "sized first correction from 11 took at most 8 steps at once, got shift {s}");
}
}

View file

@ -322,5 +322,6 @@
"shift": null,
"stale": 0
}
]
],
"history": []
}

View file

@ -15,7 +15,11 @@
"name": "devnet-3040",
"node_version": "2.1.0",
"synced": true,
"updated_ms": 1791236308123
"updated_ms": 1791236308123,
"finality": "active",
"finality_locked_index": 0,
"finality_locked_age_s": 0,
"payout_rule": "payouts follow blue confirmation, not finality"
},
"now": "2026-10-05T21:38:31Z",
"ok": true,
@ -86,7 +90,12 @@
},
"uptime_s": 8,
"url": "127.0.0.1:30463",
"workers": 0
"workers": 0,
"mode": "operator",
"tls": false,
"tls_pin": null,
"software_dev_fee_percent": 0.0,
"node_state": "ok"
},
"source": "igneum-pool v0: shares verified on the CPU warp verifier; network numbers from the pool's node (getBlockDagInfo, estimateNetworkHashesPerSecond); reward by spec 2.5 at the node's DAA score"
}

246
pool/tools/open-gate.mjs Normal file
View file

@ -0,0 +1,246 @@
#!/usr/bin/env node
// The open pool's gate (mission item 11; docs/plans/pool.md section 10.5): N members on a private fast-time network,
// paid by the coinbase rule from a share chain with no operator key; a withheld share earning nothing; a member's
// dropped share provable from its own log; the first payout inside two minutes of the first share.
//
// tools/lock/with-lock.sh run node pool/tools/open-gate.mjs [--members 100] [--daemons 10] [--nodes 4] [--secs 900]
// [--chain-share-s 0.1] [--window 2160] [--threads 1] [--scratch <dir>] [--node <igneumd>] [--miner <igneum-miner>]
// [--pool <igneum-pool>] [--genesis-bits 0x1e400000] [--withhold 1] (the last daemon withholds its members' shares)
// [--dataset-log2 24] the private network's genesis dataset (2^24 words = 64 MiB per process against the
// devnet's 2^28 = 1 GiB), so 100 CPU members, 10 daemons and 4 nodes fit one 64 GB machine
//
// Topology: `--nodes` igneumd on a fast-time private network (ports 30500 and up, network id igneum-devnet-3050, data
// under the scratch dir), `--daemons` open-pool daemons (each on one of the nodes, each with its own share-chain view,
// peered in a ring plus two chords), `--members` miners spread over the daemons (one key and one payout address each,
// `--threads` CPU threads each). The override sets pool_split_activation_daa 0 so every blue block pays by its split.
//
// Never touches the live devnet (26610/26611) or other agents' port ranges (27800+, 28500+, 29300+, 29500+, 29700+,
// 29800+, 29900+, 29950+, 30400+ the pool measure). Output: one line per step and <scratch>/summary.json; every
// number names its source.
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, appendFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const ROOT = join(here, '..', '..');
const args = process.argv.slice(2);
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : d; };
const MEMBERS = +opt('--members', 100);
const DAEMONS = +opt('--daemons', 10);
const NODES = +opt('--nodes', 4);
const SECS = +opt('--secs', 900);
const CHAIN_SHARE_S = opt('--chain-share-s', '0.1');
const WINDOW = +opt('--window', 2160);
const THREADS = +opt('--threads', 1);
const WITHHOLD = +opt('--withhold', 1);
const SCRATCH = opt('--scratch', process.env.SCRATCH || `/tmp/igneum-pool-open-gate`);
const NODE = opt('--node', `${ROOT}/vendor/igneum-node-pf/target/release/igneumd`);
const MINER = opt('--miner', `${ROOT}/vendor/igneum-node-pf/target/release/igneum-miner`);
const POOL = opt('--pool', `${ROOT}/pool/target/release/igneum-pool`);
const GENESIS_BITS = Number(opt('--genesis-bits', '0x1e400000'));
const DATASET_LOG2 = +opt('--dataset-log2', 24);
const BASE = 30500, SUFFIX = 3050, DAEMON_BASE = 30600, P2P_BASE = 30700, HTTP_BASE = 30800;
// the share chain's first target: the network's genesis block target (Kaspa compact bits to a 64-bit target) eight times easier
const target64 = (bits) => { const exp = BigInt(bits >>> 24), mant = BigInt(bits & 0xffffff); const t256 = exp <= 3n ? mant >> (8n * (3n - exp)) : mant << (8n * (exp - 3n)); return t256 >> 192n; };
const CHAIN_GENESIS_TARGET = (target64(GENESIS_BITS) << 3n).toString(16).padStart(16, '0');
const FAST = `${ROOT}/infra/fast-time/override-60x.json`;
const log = (...a) => { const l = new Date().toISOString().slice(11, 23) + ' ' + a.join(' '); console.log(l); try { appendFileSync(join(SCRATCH, 'gate.log'), l + '\n'); } catch {} };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
for (const b of [NODE, MINER, POOL]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
rmSync(SCRATCH, { recursive: true, force: true }); mkdirSync(SCRATCH, { recursive: true });
const started = [];
const stopAll = () => { for (const p of started.splice(0).reverse()) { try { p.kill('SIGINT'); } catch {} } };
process.on('SIGINT', () => { stopAll(); process.exit(130); });
process.on('SIGTERM', () => { stopAll(); process.exit(143); });
// a harness fault never leaves a network behind (the first smoke run did, and the next run read the old daemons)
process.on('uncaughtException', (e) => { console.error(e); stopAll(); process.exit(1); });
process.on('unhandledRejection', (e) => { console.error(e); stopAll(); process.exit(1); });
const summary = { started: new Date().toISOString(), members: MEMBERS, daemons: DAEMONS, nodes: NODES, secs: SECS, chain_share_s: +CHAIN_SHARE_S, window: WINDOW, threads: THREADS, genesis_bits: '0x' + GENESIS_BITS.toString(16), dataset_log2: DATASET_LOG2, withhold: WITHHOLD, steps: {} };
const save = () => writeFileSync(join(SCRATCH, 'summary.json'), JSON.stringify(summary, null, 2));
// the override: the 60x profile with real proof of work at a CPU difficulty and the split switch at 0
const override = join(SCRATCH, 'override.json');
let text = readFileSync(FAST, 'utf8')
.replace(/"skip_proof_of_work":\s*(true|false)/, '"skip_proof_of_work": false')
.replace(/"genesis_bits":\s*\d+/, `"genesis_bits": ${GENESIS_BITS}`);
if (!/"skip_proof_of_work": false/.test(text) || !text.includes(`"genesis_bits": ${GENESIS_BITS}`)) throw new Error('override edit failed');
text = text.replace(/"pow_genesis_dataset_log2":\s*\d+/, `"pow_genesis_dataset_log2": ${DATASET_LOG2}`);
if (!text.includes(`"pow_genesis_dataset_log2": ${DATASET_LOG2}`)) throw new Error('dataset edit failed');
if (/"pool_split_activation_daa"/.test(text)) text = text.replace(/"pool_split_activation_daa":\s*\d+/, '"pool_split_activation_daa": 0');
else text = text.replace(/\n}\s*$/, ',\n "pool_split_activation_daa": 0\n}\n');
if (!text.includes('"pool_split_activation_daa": 0')) throw new Error('split switch edit failed');
writeFileSync(override, text);
function run(name, bin, a, env = {}, nice = 0) {
const out = openSync(join(SCRATCH, `${name}.log`), 'a');
const p = nice ? spawn('nice', ['-n', String(nice), bin, ...a], { stdio: ['ignore', out, out], env: { ...process.env, ...env } }) : spawn(bin, a, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
p.on('exit', (code) => { p.exitCode2 = code; });
started.push(p);
appendFileSync(join(SCRATCH, 'pids'), `${p.pid} ${name}\n`);
return p;
}
const logOf = (name) => { try { return readFileSync(join(SCRATCH, `${name}.log`), 'utf8'); } catch { return ''; } };
async function api(d, path) { const r = await fetch(`http://127.0.0.1:${HTTP_BASE + d}${path}`); return r.json(); }
let rpcId = 0;
async function evm(method, params = [], n = 0) {
const r = await fetch(`http://127.0.0.1:${BASE + n * 4 + 3}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }) });
const j = await r.json(); if (j.error) throw new Error(j.error.message || JSON.stringify(j.error)); return j.result;
}
const FT = { IGNEUM_POW_DAY_MS: '1440000' };
// a balance before the execution layer has executed a chain block reads as 0 (the follower answers an error until then)
const bal = async (a) => { try { return BigInt(await evm('eth_getBalance', [a, 'latest'])); } catch (e) { if (/no chain block/i.test(e.message)) return 0n; throw e; } };
// 1. the nodes, a chain of peers: each started once the one before answers, each adding the one before as a peer
// (--addpeer retries; a --connect at start raced the earlier node's listener on the box, 7 October 2026: nodes 2 and
// 3 of the first box run never peered, three partitions of one DAG, three epoch seeds, and the share chain was blamed)
const nodePorts = (n) => ({ grpc: BASE + n * 4, p2p: BASE + n * 4 + 1, json: BASE + n * 4 + 2, evm: BASE + n * 4 + 3 });
const rpcs = [];
for (let n = 0; n < NODES; n++) {
const p = nodePorts(n);
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--appdir=${join(SCRATCH, 'node' + n)}`, `--rpclisten=127.0.0.1:${p.grpc}`, `--rpclisten-json=127.0.0.1:${p.json}`, `--evm-rpclisten=127.0.0.1:${p.evm}`, `--listen=127.0.0.1:${p.p2p}`,
`--override-params-file=${override}`, '--loglevel=info', '--yes'];
if (n > 0) a.push(`--addpeer=127.0.0.1:${nodePorts(n - 1).p2p}`); else a.push('--outpeers=0');
run(`node${n}`, NODE, a, FT);
const rpc = await connectRpc(`ws://127.0.0.1:${p.json}`, { attempts: 120, waitMs: 500 });
if (!rpc) { log(`node ${n} answered no RPC in 60 s`); stopAll(); process.exit(2); }
rpcs.push(rpc);
}
// the chain-side fact, never a process name: every node past the first has a peer, and the DAG is one
const peersOf = async (n) => { try { const r = await rpcs[n].call('getConnectedPeerInfo', {}); return (r.peerInfo || r.infos || r.peers || []).length; } catch (e) { return -1; } };
for (let n = 1; n < NODES; n++) {
let ok = false;
for (let i = 0; i < 60 && !ok; i++) { await sleep(1000); ok = (await peersOf(n)) >= 1; }
if (!ok) { log(`node ${n} has no peer after 60 s (getConnectedPeerInfo)`); stopAll(); process.exit(2); }
}
const daaOf = async (n) => { try { return (await rpcs[n].call('getBlockDagInfo', {})).virtualDaaScore; } catch { return null; } };
log(`${NODES} nodes up, peers ${(await Promise.all(Array.from({ length: NODES }, (_, n) => peersOf(n)))).join('/')}`);
// 2. the daemons: each on a node, no payout key, the share chain peered in a ring with two chords
const daemonNode = (d) => d % NODES;
for (let d = 0; d < DAEMONS; d++) {
const n = nodePorts(daemonNode(d));
const peers = new Set([(d + 1) % DAEMONS, (d + DAEMONS - 1) % DAEMONS, (d + 3) % DAEMONS, (d + 7) % DAEMONS]);
peers.delete(d);
const a = ['--open', '--node', `grpc://127.0.0.1:${n.grpc}`, '--evm-rpc', `http://127.0.0.1:${n.evm}`, '--listen', `127.0.0.1:${DAEMON_BASE + d}`, '--http', `127.0.0.1:${HTTP_BASE + d}`,
'--p2p-listen', `127.0.0.1:${P2P_BASE + d}`, '--data-dir', join(SCRATCH, `daemon${d}`), '--network', 'devnet', '--chain-share-s', CHAIN_SHARE_S, '--window-shares', String(WINDOW),
'--name', `open daemon ${d}`, '--public-url', `127.0.0.1:${DAEMON_BASE + d}`, '--share-interval-s', '10', '--verify-threads', '2', '--snapshot-interval-s', '15', '--chain-genesis-target64', CHAIN_GENESIS_TARGET];
for (const p of peers) a.push('--peer', `127.0.0.1:${P2P_BASE + p}`);
if (WITHHOLD && d === DAEMONS - 1) a.push('--withhold-shares');
run(`daemon${d}`, POOL, a, FT);
}
for (let d = 0; d < DAEMONS; d++) for (let i = 0; i < 60; i++) { await sleep(500); try { const h = await fetch(`http://127.0.0.1:${HTTP_BASE + d}/api/stats`); if (h.ok) break; } catch {} }
for (let d = 0; d < DAEMONS; d++) if (/POOL NOT STARTED/.test(logOf(`daemon${d}`))) { log(`daemon ${d} did not start: ${logOf(`daemon${d}`).trim().split('\n').pop()}`); stopAll(); process.exit(2); }
log(`${DAEMONS} daemons up${WITHHOLD ? ` (daemon ${DAEMONS - 1} withholds its shares)` : ''}`);
// 3. the members: one key and one payout address each, spread over the daemons
const ADDR = Array.from({ length: MEMBERS }, (_, i) => '0x' + (i + 1).toString(16).padStart(4, '0').repeat(10));
const memberDaemon = (m) => m % DAEMONS;
for (let m = 0; m < MEMBERS; m++) {
const d = memberDaemon(m);
const n = nodePorts(daemonNode(d));
run(`member${m}`, MINER, ['mine', `grpc://127.0.0.1:${n.grpc}`, String(THREADS), String(SECS + 300), `open-m${m}`, '--pool', `127.0.0.1:${DAEMON_BASE + d}`, '--evm-address', ADDR[m], '--worker-name', `m${m}`, '--status-secs', '60', '--network', 'devnet'], FT, 15);
if (m % 10 === 9) await sleep(200);
}
log(`${MEMBERS} members started`);
const t0 = Date.now();
const firstShareMs = {}, firstPaidMs = {};
const samples = [];
const balance0 = {};
for (const a of ADDR) balance0[a] = (await bal(a));
// the first-payout watcher: every 5 s, the first share of every address (header time, from the daemons' chains) and
// the first chain credit (eth_getBalance), so the latency is read to 5 s
let watching = true;
const watcher = (async () => {
while (watching) {
await sleep(5000);
try {
for (let d = 0; d < DAEMONS; d++) {
const sh = await api(d, `/api/open/shares?from=0&limit=2048`);
for (const s of sh.shares) if (firstShareMs[s.address] == null || s.timestamp < firstShareMs[s.address]) firstShareMs[s.address] = s.timestamp;
}
for (const a of ADDR) if (firstPaidMs[a] == null) { const b = await bal(a); if (b > balance0[a]) firstPaidMs[a] = Date.now(); }
} catch (e) { log(`watcher: ${e.message}`); }
}
})();
// 4. the run: every 30 s, every daemon's chain and the chain-side balances
while (Date.now() - t0 < SECS * 1000) {
await sleep(30000);
try {
const opens = await Promise.all(Array.from({ length: DAEMONS }, (_, d) => api(d, '/api/open').then(j => j.open)));
const stats0 = await api(0, '/api/stats');
const heights = opens.map(o => o.height);
const tips = new Set(opens.map(o => o.tip));
const nPaid = ADDR.filter(a => firstPaidMs[a] != null).length;
const daas = await Promise.all(Array.from({ length: NODES }, (_, n) => daaOf(n)));
samples.push({ t: Math.round((Date.now() - t0) / 1000), node_daa: daas, heights, tips: tips.size, accepted: opens.map(o => o.accepted), stale: opens.map(o => o.stale), reorgs: opens.map(o => o.reorgs), rejected: opens.map(o => o.rejected), blocks: opens.map(o => o.blocks_found), paid_members: nPaid, daa: stats0.network.daa_score, net_hashrate: stats0.network.hashrate });
log(`t=${Math.round((Date.now() - t0) / 1000)}s nodes_daa=${daas.join('/')} heights=${Math.min(...heights)}..${Math.max(...heights)} tips=${tips.size} stale=${opens.reduce((s, o) => s + o.stale, 0)} reorgs=${opens.reduce((s, o) => s + o.reorgs, 0)} rejected=${opens.reduce((s, o) => s + o.rejected, 0)} blocks=${opens.reduce((s, o) => s + o.blocks_found, 0)} paid_members=${nPaid}/${MEMBERS} with_shares=${Object.keys(firstShareMs).length} daa=${stats0.network.daa_score} target=${opens[0].target64}`);
} catch (e) { log(`sample failed: ${e.message}`); }
}
// 5. the verdicts
watching = false; await watcher;
const opens = await Promise.all(Array.from({ length: DAEMONS }, (_, d) => api(d, '/api/open').then(j => j.open)));
const credits = {};
for (const a of ADDR) credits[a] = (await bal(a)) - balance0[a];
const withheld = WITHHOLD ? ADDR.filter((_, m) => memberDaemon(m) === DAEMONS - 1) : [];
const honest = ADDR.filter(a => !withheld.includes(a));
const paidHonest = honest.filter(a => credits[a] > 0n);
const withShares = honest.filter(a => firstShareMs[a] != null);
// the withheld daemon's shares are in nobody else's chain; its members earn nothing from others' blocks (its own
// blocks, if any, pay its own window: those credits are expected and counted apart)
const withheldSeen = [];
for (let d = 0; d < DAEMONS - (WITHHOLD ? 1 : 0); d++) { const sh = await api(d, '/api/open/shares?from=0&limit=2048'); for (const s of sh.shares) if (withheld.includes(s.address)) withheldSeen.push(s.hash); }
const withheldBlocks = WITHHOLD ? opens[DAEMONS - 1].blocks_found : 0;
// an honest daemon's block never pays a withheld address (its split is the chain the withholder kept its shares out of)
let honestBlocks = 0, withheldPaidByHonest = 0;
for (let d = 0; d < DAEMONS - (WITHHOLD ? 1 : 0); d++) { const bl = await api(d, '/api/blocks?limit=1000'); for (const b of bl.blocks) { honestBlocks++; if (b.payees.some(p => withheld.includes(p.address))) withheldPaidByHonest++; } }
// first payout latency: from the member's first share (header time) to the first chain credit
const latencies = honest.filter(a => firstShareMs[a] && firstPaidMs[a]).map(a => (firstPaidMs[a] - firstShareMs[a]) / 1000);
latencies.sort((x, y) => x - y);
const pct = (q) => latencies.length ? +latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * q))].toFixed(1) : null;
// the drop proof: a share from member 0's own log, re-verified by the binary, against the next block any daemon found
const shareLine = (logOf('daemon0').match(/OPEN SHARE [0-9a-f]+ height \d+/g) || [])[0] || null;
let proof = null;
try {
const lines = readFileSync(join(SCRATCH, 'daemon0', 'shares.log'), 'utf8').trim().split('\n').filter(Boolean);
const first = lines[0];
const share = JSON.parse(first.replace(/^SHARE /, ''));
const all = (await api(0, '/api/blocks?limit=1000')).blocks.filter(b => b.status !== 'orphan');
const blocks = all.some(b => b.status === 'confirmed') ? all.filter(b => b.status === 'confirmed') : all;
const after = blocks.filter(b => b.ts_ms >= share.timestamp).sort((x, y) => x.ts_ms - y.ts_ms)[0];
let blockRaw = null;
if (after) { const s = await api(0, `/api/open/share/${after.hash}`); if (s.ok) blockRaw = JSON.stringify(s.share.raw); }
const vargs = ['verify-share', first]; if (blockRaw) { writeFileSync(join(SCRATCH, 'proof-block.json'), blockRaw); vargs.push('--block', join(SCRATCH, 'proof-block.json')); }
const v = spawnSync(POOL, vargs, { encoding: 'utf8', timeout: 120000, env: { ...process.env, ...FT } });
proof = { share: share.hash, height: share.height, block: after?.hash || null, exit: v.status, out: (v.stdout || '').trim().split('\n'), source: 'igneum-pool verify-share on daemon0/shares.log line 1 against the first confirmed block after it (its raw block from /api/open/share)' };
} catch (e) { proof = { error: e.message }; }
summary.steps.run = { samples, source: 'GET /api/open and /api/open/shares on every daemon every 30 s; eth_getBalance on node 0' };
summary.steps.verdict = {
honest_members: honest.length, honest_with_a_share: withShares.length, honest_paid_by_coinbase: paidHonest.length,
withheld_members: withheld.length, withheld_shares_seen_elsewhere: withheldSeen.length, withheld_credits_ign: withheld.map(a => Number(credits[a]) / 1e18), withheld_daemon_blocks: withheldBlocks,
honest_blocks: honestBlocks, honest_blocks_paying_a_withheld_address: withheldPaidByHonest,
first_payout_after_first_share_s: { n: latencies.length, p50: pct(0.5), p90: pct(0.9), max: latencies.length ? +latencies[latencies.length - 1].toFixed(1) : null, under_120_s: latencies.filter(x => x <= 120).length },
chains: opens.map((o, d) => ({ daemon: d, height: o.height, tip: o.tip, accepted: o.accepted, stale: o.stale, reorgs: o.reorgs, rejected: o.rejected, blocks: o.blocks_found, last_reject: o.last_reject })),
tips_agree: new Set(opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)).map(o => o.tip)).size === 1,
nodes_daa: await Promise.all(Array.from({ length: NODES }, (_, n) => daaOf(n))),
nodes_peers: await Promise.all(Array.from({ length: NODES }, (_, n) => peersOf(n))),
// the honest daemons' fork rate (the withholder's view diverges by design: its own shares are in its chain alone)
stale_rate: (() => { const h = opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)); const s = h.reduce((x, o) => x + o.stale, 0), a = h.reduce((x, o) => x + o.accepted, 0); return a ? +(s / a).toFixed(4) : null; })(),
withholder_stale_rate: WITHHOLD ? +(opens[DAEMONS - 1].stale / Math.max(1, opens[DAEMONS - 1].accepted)).toFixed(4) : null,
total_credited_ign: Number(ADDR.reduce((s, a) => s + credits[a], 0n)) / 1e18,
drop_proof: proof,
first_share_line: shareLine,
pass: paidHonest.length === honest.length && withheldSeen.length === 0 && withheldPaidByHonest === 0 && latencies.length > 0 && pct(0.9) != null && pct(0.9) <= 120 && proof && proof.exit === 0,
source: 'eth_getBalance before and after on node 0 (the coinbase rule, pool_split_activation_daa 0); the daemons\' /api/open; daemon0/shares.log; igneum-pool verify-share',
};
save();
log(`VERDICT ${summary.steps.verdict.pass ? 'PASS' : 'FAIL'}: ${JSON.stringify({ ...summary.steps.verdict, chains: undefined, drop_proof: proof && { exit: proof.exit, out: proof.out } })}`);
stopAll();
await sleep(2000);
log(`done; summary at ${join(SCRATCH, 'summary.json')}`);
process.exit(summary.steps.verdict.pass ? 0 : 1);

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>__POOL_NAME__</title>
<meta name="description" content="An Igneum mining pool: hash rate, miners, blocks, payouts, and how to connect igneum-miner.">
<meta name="description" content="An Igneum mining pool: hash rate, miners, blocks, payouts, finality, and how to connect igneum-miner.">
<meta name="theme-color" content="#0C0C0E">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E" type="image/svg+xml">
<style>
@ -48,6 +48,9 @@ tr:last-child td{border-bottom:0}
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2)}
.chip.ok{border-color:var(--green);color:var(--green)}.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.bad{border-color:var(--ember);color:var(--ember)}
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
.bar{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;font-family:var(--f-mono);font-size:13px;color:var(--ink-2);margin-top:12px}
.bar .dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--ash);margin-right:6px}
.bar .dot.ok{background:var(--green)}.bar .dot.bad{background:var(--ember)}.bar .dot.hot{background:var(--molten)}
pre{background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:14px 16px;font:500 13px/1.5 var(--f-mono);overflow-x:auto;margin:12px 0 0;color:var(--bone)}
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
@ -59,6 +62,7 @@ dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
.two{display:grid;gap:var(--gap);grid-template-columns:1fr;margin-top:var(--gap)}
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
svg.spark{width:100%;height:64px;display:block;margin-top:10px}
main{padding-bottom:clamp(56px,8vw,96px)}
.foot{border-top:1px solid var(--line);padding:28px 0;font-size:13px;color:var(--ash)}
</style>
@ -75,9 +79,15 @@ main{padding-bottom:clamp(56px,8vw,96px)}
</nav>
<main id="main" class="wrap">
<div class="head">
<div class="eyebrow">__NETWORK__ · PPLNS · fee __FEE__% · minimum payout __MIN_PAYOUT__ IGN</div>
<div class="eyebrow">__NETWORK__ · __MODE__ pool · fee __FEE__% · minimum payout __MIN_PAYOUT__ IGN</div>
<h1>__POOL_NAME__</h1>
<p>Your vote key stays yours: the pool names your key in every header it gives you, so your finality weight follows you (spec 09). The pool pays 80% of each block it finds across the last shares; the 20% proving share goes to provers by the chain's own rule and never passes through here.</p>
<p id="lede">Your vote key stays yours: the pool names your key in every header it gives you, so your finality weight follows you (spec 09). The pool pays 80% of each block it finds across the last shares; the 20% proving share goes to provers by the chain's own rule and never passes through here.</p>
<div class="bar" aria-live="polite">
<span><span class="dot" id="d-node"></span><span id="t-node">node</span></span>
<span><span class="dot" id="d-fin"></span><span id="t-fin">finality</span></span>
<span id="t-rule">payouts follow blue confirmation, not finality</span>
<span id="t-tls"></span>
</div>
</div>
<div class="strip" aria-live="polite">
<div class="cell"><div class="k">Pool hash rate</div><div class="v" id="p-hr">0</div><div class="s" id="p-hr1h">10 min / 1 h</div></div>
@ -90,24 +100,38 @@ main{padding-bottom:clamp(56px,8vw,96px)}
<div class="cell"><div class="k">Paid 24 h</div><div class="v" id="p-paid">0<small>IGN</small></div><div class="s" id="p-paid-s">pending</div></div>
</div>
<div class="card" id="open-card" hidden>
<div class="viz-head"><h2>The share chain</h2><div class="eyebrow">no operator: every block's own coinbase pays the window</div></div>
<dl>
<dt>Height</dt><dd id="o-height"></dd>
<dt>Tip</dt><dd class="hash" id="o-tip"></dd>
<dt>Target</dt><dd class="hash" id="o-target"></dd>
<dt>Shares</dt><dd id="o-shares"></dd>
<dt>Window</dt><dd id="o-window"></dd>
</dl>
<div class="tbl"><table><thead><tr><th>Address</th><th class="n">Share of the next block</th></tr></thead><tbody id="o-split"></tbody></table></div>
<p class="note">A share is a block template at the chain's target with your key in the header and your address in the coinbase. Shares form a chain between the members; a block found on it pays the last shares by the chain's rule, which every node applies from the coinbase alone. There is no balance to withhold and no key that could. The software dev fee is one entry of the split, the same 1% the solo miner and pool-0 carry.</p>
</div>
<div class="card" id="connect">
<div class="viz-head"><h2>Connect</h2><div class="eyebrow">igneum-miner 0.3.6 or later</div></div>
<p class="note" style="margin-top:0">One command per card. The payout address is an EVM address you hold the key for; the worker name labels the card on this page. Your own node is optional: with one, the miner checks the pool's seeds and templates against it, votes on finality with your key, and submits found blocks there too. Without one it hashes and does not vote.</p>
<pre>igneum-miner mine grpc://127.0.0.1:26610 1 100000000 rig1 --pool __POOL_URL__ --evm-address 0xYOURADDRESS --worker-name rig1-gpu0 \
<div class="viz-head"><h2>Connect</h2><div class="eyebrow">igneum-miner 0.3.19 or later</div></div>
<p class="note" style="margin-top:0">One command per card. The payout address is an EVM address you hold the key for; the worker name labels the card on this page. Your own node is optional on an operator pool: with one, the miner checks the pool's seeds and templates against it, votes on finality with your key, and submits found blocks there too. Without one it hashes and does not vote. The open pool needs your node: the daemon beside it builds your templates from it.</p>
<pre>igneum-miner mine grpc://127.0.0.1:26610 1 100000000 rig1 --pool __POOL_URL__ __TLS__ --evm-address 0xYOURADDRESS --worker-name rig1-gpu0 \
--worker ./igneum-worker-cuda --worker-args "--device 0 --pack packs/devnet"
# without a node of your own (hashes, does not vote):
igneum-miner mine none 1 100000000 rig1 --pool __POOL_URL__ --evm-address 0xYOURADDRESS --worker-name rig1-gpu0 --worker ./igneum-worker-cuda</pre>
igneum-miner mine none 1 100000000 rig1 --pool __POOL_URL__ __TLS__ --evm-address 0xYOURADDRESS --worker-name rig1-gpu0 --worker ./igneum-worker-cuda</pre>
<dl>
<dt>HiveOS</dt><dd>Flight Sheet pool URL <span class="mono">pool://__POOL_URL__</span>, wallet <span class="mono">0xYOURADDRESS.%WORKER_NAME%</span>; the rest as on igneum.network/miner</dd>
<dt>Transport</dt><dd id="c-tls">TLS 1.3 on the member port (spec 9.3); the miner's <span class="mono">--pool-tls</span> uses the system roots, <span class="mono">--pool-pin</span> the certificate pin this pool prints at start</dd>
<dt>Shares</dt><dd>a share is a lane hash at or below your share target; the pool sets it so you send about one every 10 s and checks every one on the CPU exactly as a node checks a block</dd>
<dt>Dev fee</dt><dd>in pool mode the miner software takes no dev fee: the pool's fee (__FEE__%) is the only fee, and it is in the welcome message every miner prints at start</dd>
<dt>Fees</dt><dd>the solo miner's software dev fee is 1 block in 100; this pool's fee is __FEE__%, published here and in the welcome message every miner prints at start; in pool mode the miner software takes no dev fee of its own</dd>
<dt>Pool address</dt><dd class="hash">__POOL_ADDRESS__</dd>
</dl>
</div>
<div class="card" id="lookup">
<div class="viz-head"><h2>Your address</h2><div class="eyebrow">shares, hash rate, balance, payments</div></div>
<div class="viz-head"><h2>Your address</h2><div class="eyebrow">shares, hash rate, balance, payments, 7 days</div></div>
<form class="search" id="search"><input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="0x your payout address" aria-label="Payout address"><button class="btn" type="submit">Look up</button></form>
<div class="msg" id="msg" aria-live="polite"></div>
<div id="miner" hidden>
@ -118,7 +142,11 @@ igneum-miner mine none 1 100000000 rig1 --pool __POOL_URL__ --evm-address 0xYOUR
<dt>Paid</dt><dd id="m-paid"></dd>
<dt>Blocks found</dt><dd id="m-blocks"></dd>
</dl>
<svg class="spark" id="m-spark" viewBox="0 0 600 64" preserveAspectRatio="none" aria-label="Hash rate per hour over 7 days"></svg>
<div class="s mono" id="m-spark-s" style="font-size:12px;color:var(--ash)"></div>
<div class="tbl"><table><thead><tr><th>Worker</th><th class="n">Hash rate</th><th class="n">Accepted</th><th class="n">Stale</th><th class="n">Rejected</th><th>Shift</th><th>Proving</th><th>Status</th></tr></thead><tbody id="m-workers"></tbody></table></div>
<div class="viz-head" style="margin-top:18px"><h2>Payments to this address</h2><div class="eyebrow">newest first</div></div>
<div class="tbl"><table><thead><tr><th>Time</th><th class="n">IGN</th><th>Tx</th><th>Status</th></tr></thead><tbody id="m-pay"></tbody></table></div>
<p class="note">A worker that also proves shards gives about 4% of its hash rate to the prover (measured on an RTX 5090, bench-log "proving v1"); proving income is paid to your own key by the chain and does not appear here. The Proving column says when the miner reports it.</p>
</div>
</div>
@ -129,7 +157,7 @@ igneum-miner mine none 1 100000000 rig1 --pool __POOL_URL__ --evm-address 0xYOUR
<div class="tbl"><table><thead><tr><th>Block</th><th class="n">DAA</th><th>Finder</th><th class="n">Reward</th><th class="n">Effort</th><th>Status</th><th>Time</th></tr></thead><tbody id="blocks-body"><tr><td colspan="7">Loading.</td></tr></tbody></table></div>
</div>
<div class="card" id="payments" style="margin-top:0">
<div class="viz-head"><h2>Payments</h2><div class="eyebrow">EVM transfers from the pool address</div></div>
<div class="viz-head"><h2>Payments</h2><div class="eyebrow" id="pay-eyebrow">EVM transfers from the pool address</div></div>
<div class="tbl"><table><thead><tr><th>Time</th><th>Address</th><th class="n">IGN</th><th>Tx</th><th>Status</th></tr></thead><tbody id="pay-body"><tr><td colspan="5">Loading.</td></tr></tbody></table></div>
</div>
</div>
@ -137,47 +165,89 @@ igneum-miner mine none 1 100000000 rig1 --pool __POOL_URL__ --evm-address 0xYOUR
<div class="card">
<div class="viz-head"><h2>API</h2><div class="eyebrow">JSON, GET, CORS open</div></div>
<dl>
<dt>/api/stats</dt><dd><a class="hash" href="/api/stats">pool hash rate, miners, workers, blocks 24 h, last block, fee, minimum payout, luck, and the network's difficulty, hash rate and reward</a></dd>
<dt>/api/blocks</dt><dd><a class="hash" href="/api/blocks">blocks found with DAA score, time, finder, status and the PPLNS split</a></dd>
<dt>/api/miners/&lt;address&gt;</dt><dd class="hash">hash rate, shares, workers, balance, paid, payments</dd>
<dt>/api/stats</dt><dd><a class="hash" href="/api/stats">pool hash rate, miners, workers, blocks 24 h, last block, fee, minimum payout, luck, the network's difficulty, hash rate, reward and finality</a></dd>
<dt>/api/blocks</dt><dd><a class="hash" href="/api/blocks">blocks found with DAA score, time, finder, status and the split</a></dd>
<dt>/api/miners/&lt;address&gt;</dt><dd class="hash">hash rate, shares, workers, balance, paid, payments, hourly history</dd>
<dt>/api/payments</dt><dd><a class="hash" href="/api/payments">every payout with its transaction hash</a></dd>
<dt>/api/pool-stats</dt><dd><a class="hash" href="/api/pool-stats">the flat camelCase object pool dashboards poll</a></dd>
<dt>/metrics</dt><dd><a class="hash" href="/metrics">Prometheus text</a></dd>
<dt>/health</dt><dd><a class="hash" href="/health">200 when the node is synced and answered a template in the last 30 s, else 503</a></dd>
<dt>/api/open</dt><dd><a class="hash" href="/api/open">the share chain (open pool)</a></dd>
</dl>
</div>
</main>
<footer class="foot"><div class="wrap">Igneum. Mined by GPUs. Proven by fire. · <a href="https://igneum.network/">igneum.network</a> · Nothing on this page is an offer to sell anything.</div></footer>
<script>
// Q69: one formatter set, the site's conventions (polish 4.3): hash rate 1 decimal on a kH to PH ladder, counts with
// en-GB separators, difficulty one spelling, IGN 4 decimals on tiles and 6 in tables.
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;');
const hr = h => { h = Number(h) || 0; const u = ['H/s','kH/s','MH/s','GH/s','TH/s']; let i = 0; while (h >= 1000 && i < u.length - 1) { h /= 1000; i++; } return h.toFixed(i ? 2 : 0) + ' ' + u[i]; };
const nf = new Intl.NumberFormat('en-GB');
const num = n => nf.format(Math.round(Number(n) || 0));
const hr = h => { h = Number(h) || 0; const u = ['H/s','kH/s','MH/s','GH/s','TH/s','PH/s']; let i = 0; while (h >= 1000 && i < u.length - 1) { h /= 1000; i++; } return (i ? h.toFixed(1) : Math.round(h)) + ' ' + u[i]; };
const diff = d => { d = Number(d) || 0; const u = ['','k','M','G','T','P']; let i = 0; while (d >= 1000 && i < u.length - 1) { d /= 1000; i++; } return (i ? d.toFixed(2) : Math.round(d)) + (i ? ' ' + u[i] : ''); };
const ign4 = v => (Number(v) || 0).toFixed(4), ign6 = v => (Number(v) || 0).toFixed(6);
const short = h => h ? h.slice(0, 10) + '…' : '';
const rel = ms => { const d = (Date.now() - ms) / 1000; return d < 60 ? Math.round(d) + ' s ago' : d < 3600 ? Math.round(d / 60) + ' min ago' : Math.round(d / 3600) + ' h ago'; };
const chip = s => `<span class="chip ${s === 'confirmed' ? 'ok' : s === 'orphan' || s === 'failed' ? 'bad' : 'hot'}">${esc(s)}</span>`;
const chip = s => `<span class="chip ${s === 'confirmed' || s === 'coinbase' ? 'ok' : s === 'orphan' || s === 'failed' ? 'bad' : 'hot'}">${esc(s)}</span>`;
let openMode = false;
async function stats() {
try {
const r = await fetch('/api/stats'); const s = await r.json(); const p = s.pool, n = s.network;
openMode = p.mode === 'open';
$('p-hr').textContent = hr(p.hashrate); $('p-hr1h').textContent = hr(p.hashrate_1h) + ' over 1 h';
$('p-miners').textContent = p.miners + ' / ' + p.workers;
$('p-blocks').textContent = p.blocks_24h; $('p-blocks-s').textContent = p.blocks_confirmed_24h + ' confirmed / ' + p.blocks_orphaned_24h + ' orphaned, ' + p.blocks_total + ' total';
$('p-miners').textContent = num(p.miners) + ' / ' + num(p.workers);
$('p-blocks').textContent = num(p.blocks_24h); $('p-blocks-s').textContent = num(p.blocks_confirmed_24h) + ' confirmed / ' + num(p.blocks_orphaned_24h) + ' orphaned, ' + num(p.blocks_total) + ' total';
if (p.last_block) { $('p-last').textContent = short(p.last_block.hash); $('p-last-s').textContent = rel(p.last_block.ts_ms) + ', ' + p.last_block.status; }
$('n-hr').textContent = n.hashrate == null ? 'n/a' : hr(n.hashrate); $('n-diff').textContent = 'difficulty ' + Math.round(n.difficulty).toLocaleString() + ', DAA ' + n.daa_score;
$('n-reward').innerHTML = esc(n.miner_reward_ign.toFixed(4)) + '<small>IGN</small>';
$('n-hr').textContent = n.hashrate == null ? 'pending' : hr(n.hashrate); $('n-diff').textContent = 'difficulty ' + diff(n.difficulty) + ', DAA ' + num(n.daa_score);
$('n-reward').innerHTML = esc(ign4(n.miner_reward_ign)) + '<small>IGN</small>';
// Q69, Q72: luck in MiningPoolStats' convention (expected over actual; over 100% is lucky)
$('p-effort').textContent = Math.round(p.effort_current * 100) + '%'; $('p-luck').textContent = 'luck 24 h ' + (p.luck_24h ? Math.round(p.luck_24h * 100) + '%' : 'n/a');
$('p-paid').innerHTML = esc(p.paid_24h_ign.toFixed(4)) + '<small>IGN</small>'; $('p-paid-s').textContent = p.pending_balance_ign.toFixed(4) + ' IGN pending' + (p.dry_run ? ' (dry run)' : '');
} catch (e) { $('p-last-s').textContent = 'stats unavailable'; }
$('p-paid').innerHTML = esc(ign4(p.paid_24h_ign)) + '<small>IGN</small>'; $('p-paid-s').textContent = openMode ? 'paid by the coinbases' : ign4(p.pending_balance_ign) + ' IGN pending' + (p.dry_run ? ' (dry run)' : '');
// Q68, Q72, Q73: the node and the network's finality, in words
const nd = $('d-node'); nd.className = 'dot ' + (p.node_state === 'ok' ? 'ok' : p.node_state === 'syncing' ? 'hot' : 'bad');
$('t-node').textContent = p.node_state === 'ok' ? 'node ok, ' + esc(n.node_version) : p.node_state === 'unreachable' ? 'node unreachable since ' + (n.updated_ms ? rel(n.updated_ms) : 'start') : 'node ' + p.node_state;
const fd = $('d-fin'); fd.className = 'dot ' + (n.finality === 'active' ? 'ok' : n.finality === 'unknown' ? '' : 'hot');
$('t-fin').textContent = n.finality === 'active' ? 'finality active, checkpoint ' + num(n.finality_locked_index) + (n.finality_locked_age_s ? ', ' + num(n.finality_locked_age_s) + ' s ago' : '') : 'finality ' + esc(n.finality);
$('t-tls').textContent = p.tls ? 'members on TLS 1.3' : 'members in the clear';
if (openMode) {
$('lede').textContent = 'An open pool: no operator. Your daemon builds your templates from your own node with your key in the header and your address in the coinbase; shares form a chain between the members, and every block found on it pays the window from its own coinbase by the chain’s rule. Nobody holds a balance or a key for you.';
$('pay-eyebrow').textContent = 'coinbase credits, by block';
$('open-card').hidden = false;
}
} catch (e) { $('p-last-s').textContent = 'stats unavailable'; $('d-node').className = 'dot bad'; $('t-node').textContent = 'pool unreachable'; }
}
async function open() {
if (!openMode) return;
try {
const r = await fetch('/api/open'); const j = await r.json(); if (!j.ok) return; const o = j.open;
$('o-height').textContent = num(o.height) + (o.tip_age_s != null ? ' (tip ' + num(o.tip_age_s) + ' s ago)' : '');
$('o-tip').textContent = o.tip ? short(o.tip) : 'none yet';
$('o-target').textContent = (o.target64 || '') + ' (next ' + (o.next_target64 || '') + ')';
$('o-shares').textContent = num(o.accepted) + ' accepted, ' + num(o.stale) + ' stale, ' + num(o.reorgs) + ' reorgs, ' + num(o.rejected) + ' refused; ' + num(o.local_shares) + ' ours, ' + num(o.peer_shares) + ' from peers';
$('o-window').textContent = num(o.window_shares) + ' shares, ' + (o.share_ms / 1000) + ' s per share, dev fee ' + o.dev_fee_percent + '%';
$('o-split').innerHTML = o.window.length ? o.window.map(w => `<tr><td class="hash">${esc(w.address)}</td><td class="n">${(w.fraction * 100).toFixed(2)}%</td></tr>`).join('') : '<tr><td colspan="2">No share yet.</td></tr>';
} catch (e) {}
}
async function blocks() {
try {
const r = await fetch('/api/blocks?limit=25'); const j = await r.json();
$('blocks-body').innerHTML = j.blocks.length ? j.blocks.map(b => `<tr><td class="hash">${short(b.hash)}</td><td class="n">${b.daa_score}</td><td class="hash">${short(b.finder)} ${esc(b.worker)}</td><td class="n">${b.reward_ign.toFixed(4)}</td><td class="n">${Math.round(b.effort * 100)}%</td><td>${chip(b.status)}</td><td>${rel(b.ts_ms)}</td></tr>`).join('') : '<tr><td colspan="7">No block yet.</td></tr>';
$('blocks-body').innerHTML = j.blocks.length ? j.blocks.map(b => `<tr><td class="hash">${short(b.hash)}</td><td class="n">${num(b.daa_score)}</td><td class="hash">${short(b.finder)} ${esc(b.worker)}</td><td class="n">${ign6(b.reward_ign)}</td><td class="n">${Math.round(b.effort * 100)}%</td><td>${chip(b.status)}</td><td>${rel(b.ts_ms)}</td></tr>`).join('') : '<tr><td colspan="7">No block yet.</td></tr>';
} catch (e) {}
}
async function payments() {
try {
const r = await fetch('/api/payments?limit=25'); const j = await r.json();
$('pay-body').innerHTML = j.payments.length ? j.payments.map(p => `<tr><td>${rel(p.ts_ms)}</td><td class="hash">${short(p.address)}</td><td class="n">${p.amount_ign.toFixed(6)}</td><td class="hash">${p.tx_hash ? short(p.tx_hash) : ''}</td><td>${chip(p.status)}</td></tr>`).join('') : '<tr><td colspan="5">No payment yet.</td></tr>';
$('pay-body').innerHTML = j.payments.length ? j.payments.map(p => `<tr><td>${rel(p.ts_ms)}</td><td class="hash">${short(p.address)}</td><td class="n">${ign6(p.amount_ign)}</td><td class="hash">${p.tx_hash ? short(p.tx_hash) : ''}</td><td>${chip(p.status)}</td></tr>`).join('') : '<tr><td colspan="5">No payment yet.</td></tr>';
} catch (e) {}
}
function spark(h) {
const svg = $('m-spark');
if (!h.length) { svg.innerHTML = ''; $('m-spark-s').textContent = 'no hourly history yet'; return; }
const max = Math.max(...h.map(x => x.hashrate), 1), w = 600, hh = 64, n = h.length, bw = Math.max(1, w / Math.max(n, 24) - 1);
svg.innerHTML = h.map((x, i) => `<rect x="${(i * w / Math.max(n, 24)).toFixed(1)}" y="${(hh - x.hashrate / max * hh).toFixed(1)}" width="${bw.toFixed(1)}" height="${(x.hashrate / max * hh).toFixed(1)}" fill="#F2541B"><title>${new Date(x.hour_ms).toISOString().slice(0, 13)}:00Z ${hr(x.hashrate)}, ${num(x.shares)} shares</title></rect>`).join('');
$('m-spark-s').textContent = num(n) + ' hours of history, peak ' + hr(max);
}
$('search').addEventListener('submit', async e => {
e.preventDefault(); const a = $('q').value.trim();
if (!/^0x[0-9a-fA-F]{40}$/.test(a)) { $('msg').textContent = 'An address is 0x followed by 40 hex characters.'; return; }
@ -187,13 +257,16 @@ $('search').addEventListener('submit', async e => {
$('msg').textContent = m.online ? 'Online.' : (m.first_seen_ms ? 'Not connected now.' : 'No shares from this address yet.');
$('miner').hidden = false;
$('m-hr').textContent = hr(m.hashrate) + ' (10 min), ' + hr(m.hashrate_1h) + ' (1 h)';
$('m-shares').textContent = m.shares.accepted + ' accepted, ' + m.shares.stale + ' stale, ' + m.shares.rejected + ' rejected';
$('m-balance').textContent = m.balance_ign.toFixed(6) + ' IGN'; $('m-paid').textContent = m.paid_ign.toFixed(6) + ' IGN'; $('m-blocks').textContent = m.blocks;
$('m-workers').innerHTML = m.workers.length ? m.workers.map(w => `<tr><td>${esc(w.name)}</td><td class="n">${hr(w.hashrate)}</td><td class="n">${w.accepted}</td><td class="n">${w.stale}</td><td class="n">${w.rejected}</td><td>${w.shift ?? ''}</td><td>${w.proving ? 'yes' : 'no'}</td><td>${chip(w.online ? 'online' : 'offline')}</td></tr>`).join('') : '<tr><td colspan="8">No worker yet.</td></tr>';
$('m-shares').textContent = num(m.shares.accepted) + ' accepted, ' + num(m.shares.stale) + ' stale, ' + num(m.shares.rejected) + ' rejected';
$('m-balance').textContent = openMode ? 'none: the coinbase pays' : ign6(m.balance_ign) + ' IGN'; $('m-paid').textContent = ign6(m.paid_ign) + ' IGN'; $('m-blocks').textContent = num(m.blocks);
$('m-workers').innerHTML = m.workers.length ? m.workers.map(w => `<tr><td>${esc(w.name)}</td><td class="n">${hr(w.hashrate)}</td><td class="n">${num(w.accepted)}</td><td class="n">${num(w.stale)}</td><td class="n">${num(w.rejected)}</td><td>${w.shift ?? ''}</td><td>${w.proving ? 'yes' : 'no'}</td><td>${chip(w.online ? 'online' : 'offline')}</td></tr>`).join('') : '<tr><td colspan="8">No worker yet.</td></tr>';
// Q71: the payment history the API already returned
$('m-pay').innerHTML = (m.payments || []).length ? m.payments.map(p => `<tr><td>${rel(p.ts_ms)}</td><td class="n">${ign6(p.amount_ign)}</td><td class="hash">${p.tx_hash ? short(p.tx_hash) : ''}</td><td>${chip(p.status)}</td></tr>`).join('') : '<tr><td colspan="4">No payment yet.</td></tr>';
spark(m.history || []);
history.replaceState(null, '', '#lookup');
});
stats(); blocks(); payments();
setInterval(stats, 5000); setInterval(blocks, 5000); setInterval(payments, 15000);
stats().then(open); blocks(); payments();
setInterval(() => stats().then(open), 5000); setInterval(blocks, 5000); setInterval(payments, 15000);
</script>
</body>
</html>

View file

@ -279,7 +279,7 @@ pre b{color:var(--molten-text);font-weight:500}
</div>
<div class="feecard">
<div class="card">
<p>The <span data-product="name">Ember</span> software takes a visible, switchable 1% dev fee, the norm for GPU miners. One block template in 100 is requested with the dev payout address instead of yours. The fee goes to Igneum Labs LTD, the company that ships the software.</p>
<p>The <span data-product="name">Ember</span> software takes a visible, switchable 1% dev fee, the norm for GPU miners. One block template in 100 is requested with the dev payout address instead of yours. The fee goes to Igneum Labs LTD, the company that ships the software. Pool-0, the project&rsquo;s pool at the testnet go, charges the same 1% as its pool fee, published in the welcome line every miner prints and on the pool&rsquo;s page; in pool mode the software takes no dev fee of its own, so solo and pool cost the same and the choice is about variance alone. The open pool, the share chain with no operator, carries the same 1% as one entry of every block&rsquo;s split.</p>
<div class="tbl"><table style="min-width:0">
<thead><tr><th>Where</th><th>Off with</th></tr></thead>
<tbody>