Pool finished (mission item 11): pool-0's fee published beside the dev fee, TLS 1.3 on the member port with the authorize binding (spec 9.3, O-9.7 closed), the page rows Q68 to Q73, and the open pool: a share sidechain with no operator (spec 9.12)
Pool-0: welcome carries software_dev_fee_percent beside the pool fee, the page's Fees row and site/miner.html say pool-0 charges the same 1 percent as the solo dev fee; jobs and seeds carry the latency ladder's rung for 0.3.19.
TLS (pool/src/tls.rs): --tls-cert/--tls-key or --tls-self-signed (a P-256 pair under the data dir, the certificate pin printed at start); the binding is the member's BLS signature over this connection's exporter (label EXPORTER-igneum-pool-binding, context the chain id), refused on any other connection; testnet and mainnet refuse the clear without --allow-plain. HiveOS: pools:// and POOL_PIN.
Page rows: node state in words (Q68), one formatter set and luck in MiningPoolStats' convention (Q69), samples and check costs persisted (Q70), payments under the lookup (Q71), hourly history with a sparkline, --alert-webhook, /metrics, /health on the node (Q72), the network's finality state beside "payouts follow blue confirmation, not finality" (Q73).
The open pool (pool/src/sidechain.rs, open.rs, p2p.rs; igneum-pool --open): the member's own node and daemon, no payout key, no balance; every coinbase carries the member's own address, the share chain's parent (IGNS) and the window's split (IGNP); templates re-stamped on a new chain tip without a node call; shares gossiped and checked by every member (structure, the node's seeds, the PoW); heaviest work wins, a fork's loser is stale; the dev fee as one split entry; shares.log and verify-share for the drop proof; the gate harness pool/tools/open-gate.mjs. The node side (the executor's split from pool_split_activation_daa) is on the fork branch pool-finish-node.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 57954a43eb)
This commit is contained in:
parent
17078e1721
commit
c83ea0f5d4
26 changed files with 2454 additions and 97 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -326,3 +326,111 @@ its pack is prepared ahead (the pool's `seeds` line carries `next_epoch_seed` fo
|
|||
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, a PPLNS window of 2,160 shares including the
|
||||
share itself, the dev fee as one split entry.
|
||||
|
||||
| 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 node's seeds, the PoW), the shares log, orphans waiting for a parent, the API |
|
||||
| `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: `tools/lock/with-lock.sh run node pool/tools/open-gate.mjs` on this Mac (nothing the network depends on runs here;
|
||||
the box was at load 83 on 96 threads with 52 GB free, so the run stayed on the Mac): 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); 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.
|
||||
|
||||
GATE_RESULTS_PLACEHOLDER
|
||||
|
||||
### 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 |
|
||||
|
|
|
|||
|
|
@ -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 a retarget over the last 30 shares' header timestamps, clamped to a factor of 4 per share, never above 2^62; the first target is the block target eight times easier. 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 those of the member's own node for the share's DAA score, 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 | 10 s, 2,160, 1 percent, at most 256 | 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
36
pool/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,54 @@ 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 |
|
||||
| `--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` | `10`, `2160`, `1`, `igneum-open-v1` | 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 +111,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 +186,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 +212,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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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,13 +245,33 @@ 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"}"#),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,27 @@ pub struct Config {
|
|||
/// 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,
|
||||
/// 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 test flag: keep this daemon's own shares to itself (the withheld-share case of the gate)
|
||||
pub withhold_shares: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -48,7 +69,10 @@ impl Config {
|
|||
\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]]"
|
||||
);
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
|
@ -78,6 +102,19 @@ impl Config {
|
|||
snapshot_interval_s: 15,
|
||||
node_wait_secs: 60,
|
||||
template_timeout_s: 20,
|
||||
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(),
|
||||
withhold_shares: false,
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
|
|
@ -110,6 +147,19 @@ impl Config {
|
|||
"--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),
|
||||
"--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(),
|
||||
"--withhold-shares" => c.withhold_shares = true,
|
||||
"-h" | "--help" => Config::usage(),
|
||||
_ => Config::usage(),
|
||||
}
|
||||
|
|
@ -138,9 +188,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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -38,7 +42,27 @@ 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.
|
||||
|
|
@ -54,12 +78,17 @@ async fn main() {
|
|||
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)
|
||||
|
|
@ -132,8 +161,9 @@ 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 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),
|
||||
|
|
@ -152,6 +182,8 @@ async fn main() {
|
|||
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()))),
|
||||
});
|
||||
let chain = evm.call("eth_chainId", serde_json::json!([])).await.ok().and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
println!(
|
||||
|
|
@ -166,17 +198,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(node::status_loop(pool.clone()));
|
||||
tokio::spawn(server::listen(pool.clone(), member_listener));
|
||||
tokio::spawn(api::serve(pool.clone(), api_listener));
|
||||
{
|
||||
let (pool, payer) = (pool.clone(), payer.clone());
|
||||
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;
|
||||
|
|
|
|||
133
pool/src/node.rs
133
pool/src/node.rs
|
|
@ -35,11 +35,40 @@ 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 = match tokio::time::timeout(Duration::from_secs(pool.cfg.template_timeout_s), pool.node.get_block_template(pool.pay_address.clone(), extra_data(member, &pool.pool_address))).await {
|
||||
// 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),
|
||||
};
|
||||
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);
|
||||
|
|
@ -51,11 +80,36 @@ pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), Str
|
|||
}
|
||||
};
|
||||
pool.last_template_ok_ms.store(unix_ms(), Ordering::Relaxed);
|
||||
let mut raw = tmpl.block;
|
||||
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);
|
||||
|
|
@ -74,7 +128,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);
|
||||
|
|
@ -97,7 +154,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() {
|
||||
|
|
@ -108,7 +166,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,
|
||||
|
|
@ -121,6 +182,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();
|
||||
|
|
@ -147,6 +210,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(),
|
||||
);
|
||||
|
|
@ -166,11 +231,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(())
|
||||
}
|
||||
|
||||
|
|
@ -204,11 +269,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 {
|
||||
|
|
@ -216,7 +290,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 {
|
||||
|
|
@ -224,6 +301,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();
|
||||
|
|
@ -259,6 +353,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,
|
||||
|
|
@ -275,6 +372,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 {
|
||||
|
|
@ -368,7 +466,9 @@ pub async fn confirm_loop(pool: Arc<Pool>) {
|
|||
}
|
||||
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: {}",
|
||||
|
|
@ -412,7 +512,7 @@ pub async fn status_loop(pool: Arc<Pool>) {
|
|||
/// 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() };
|
||||
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;
|
||||
|
|
@ -427,6 +527,15 @@ pub async fn net_loop(pool: Arc<Pool>) {
|
|||
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;
|
||||
|
|
|
|||
300
pool/src/open.rs
Normal file
300
pool/src/open.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
//! 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, ProgramClass};
|
||||
use kaspa_hashes::Hash;
|
||||
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 block target: eight times easier, then the retarget takes over.
|
||||
pub const GENESIS_TARGET_SHIFT: u32 = 3;
|
||||
|
||||
pub struct Open {
|
||||
pub chain: Mutex<ShareChain>,
|
||||
pub chain_name: String,
|
||||
pub dev_fee_percent: u32,
|
||||
pub engine: Arc<IgneumEngine>,
|
||||
/// Epoch index to (epoch seed, class, era seed, ladder rung), from the node's templates: what a received share's
|
||||
/// seeds are checked against (a peer's share names its seeds; the node's are the truth)
|
||||
epochs: Mutex<HashMap<u64, (Hash, ProgramClass, Hash, u16)>>,
|
||||
/// 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,
|
||||
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>) -> 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);
|
||||
Self {
|
||||
chain: Mutex::new(ShareChain::new(params)),
|
||||
chain_name: cfg.open_chain.clone(),
|
||||
dev_fee_percent: cfg.open_dev_fee_percent,
|
||||
engine,
|
||||
epochs: 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,
|
||||
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 mut chain = self.chain.lock().unwrap();
|
||||
if chain.genesis_target.is_none() {
|
||||
let t = target64(raw.header.bits);
|
||||
chain.genesis_target = Some(t.checked_shl(GENESIS_TARGET_SHIFT).filter(|x| (*x >> GENESIS_TARGET_SHIFT) == t).unwrap_or(u64::MAX >> 1).min(crate::sidechain::MAX_TARGET));
|
||||
println!("{} OPEN chain '{}': first target {:016x} from the block target {:016x}", unix_ms(), self.chain_name, chain.genesis_target.unwrap(), t);
|
||||
}
|
||||
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();
|
||||
e.insert(info.epoch_index, (seeds.epoch, seeds.class, seeds.era, seeds.shadow_reps));
|
||||
if let Some(next) = info.next_epoch_seed {
|
||||
e.insert(info.epoch_index + 1, (next, info.next_class(), seeds.era, info.next_latency_ladder_reps as u16));
|
||||
}
|
||||
if e.len() > 64 {
|
||||
let min = info.epoch_index.saturating_sub(48);
|
||||
e.retain(|k, _| *k >= min);
|
||||
}
|
||||
}
|
||||
|
||||
/// The seeds a share must have hashed under, from the node's epochs; a share of an epoch this daemon has not
|
||||
/// seen is refused (it cannot be checked), which bounds a late joiner's sync to the epochs its node reports.
|
||||
fn seeds_for(&self, s: &Share) -> Result<EpochSeeds, String> {
|
||||
let idx = s.daa_score / pow_epoch_blocks().max(1);
|
||||
let e = self.epochs.lock().unwrap();
|
||||
let (epoch, class, era, reps) = e.get(&idx).copied().ok_or_else(|| format!("epoch {idx} (DAA {}) is not one this daemon's node has reported", s.daa_score))?;
|
||||
Ok(EpochSeeds { epoch, day: day_index(s.timestamp), class, era, shadow_reps: 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>() < 2000 {
|
||||
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 names seeds (epoch {}, class {}, rung {}) that are not the node's for its DAA score", &share.seeds.epoch[..16], share.seeds.class, share.seeds.reps));
|
||||
}
|
||||
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) {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
213
pool/src/p2p.rs
Normal file
213
pool/src/p2p.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! 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 (a reorg deeper than this needs a restart).
|
||||
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 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();
|
||||
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.chain.lock().unwrap().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
|
||||
asked_at = unix_ms();
|
||||
send(json!({"t": "get_shares", "from": share.height.saturating_sub(SYNC_BACK)}));
|
||||
} else if !e.contains("unknown") {
|
||||
println!("{} p2p {remote}: share {} REFUSED: {e}", unix_ms(), &share.hash[..16.min(share.hash.len())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"synced" => {}
|
||||
"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})
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -117,6 +128,10 @@ pub struct Pool {
|
|||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
//! 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();
|
||||
|
|
@ -27,14 +28,43 @@ pub async fn bind_listener(what: &str, addr: &str) -> Result<tokio::net::TcpList
|
|||
}
|
||||
|
||||
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);
|
||||
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) => {
|
||||
|
|
@ -53,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 {
|
||||
|
|
@ -115,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;
|
||||
|
|
@ -128,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;
|
||||
|
|
@ -163,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());
|
||||
|
|
@ -257,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) => {
|
||||
|
|
@ -314,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);
|
||||
|
|
|
|||
619
pool/src/sidechain.rs
Normal file
619
pool/src/sidechain.rs
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
//! 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;
|
||||
/// 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).
|
||||
pub const MAX_REORG_DEPTH: u64 = 256;
|
||||
pub const GENESIS_PARENT: [u8; 32] = [0u8; 32];
|
||||
|
||||
/// The seeds of a share's epoch, on the wire and in the log.
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl SeedsWire {
|
||||
pub fn from_seeds(s: &EpochSeeds) -> Self {
|
||||
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 }
|
||||
}
|
||||
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). Retarget over the
|
||||
/// last `RETARGET_SHARES` shares' header timestamps, clamped to a factor of `RETARGET_CLAMP` per share, never
|
||||
/// above `MAX_TARGET`, never below 1. Integer arithmetic only.
|
||||
pub fn target_after(&self, parent: &[u8; 32]) -> Option<u64> {
|
||||
let genesis = self.genesis_target?;
|
||||
if *parent == GENESIS_PARENT {
|
||||
return Some(genesis.min(MAX_TARGET));
|
||||
}
|
||||
let chain = self.ancestry(parent, RETARGET_SHARES + 1);
|
||||
let last = chain.first()?;
|
||||
let prev_target = last.target() as u128;
|
||||
if chain.len() < 3 {
|
||||
return Some(last.target());
|
||||
}
|
||||
let oldest = chain.last().unwrap();
|
||||
let steps = (chain.len() - 1) 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;
|
||||
// target x actual / expected: a slow chain gets an easier (larger) target
|
||||
let mut next = prev_target * actual_ms / expected_ms;
|
||||
next = next.clamp(prev_target / RETARGET_CLAMP, prev_target.saturating_mul(RETARGET_CLAMP));
|
||||
Some((next.max(1)).min(MAX_TARGET as u128) 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;
|
||||
}
|
||||
};
|
||||
let engine = IgneumEngine::new();
|
||||
match check_pow(&engine, &s) {
|
||||
Ok(()) => println!("SHARE OK {} height {} by {} lane {} <= target {} (work {} hashes)", &s.hash[..16], s.height, s.address, s.lane_hash, s.share_target64, s.work()),
|
||||
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 (within the clamp)
|
||||
let t0 = chain.genesis_target.unwrap();
|
||||
let tn = chain.target_after(&parent).unwrap();
|
||||
assert!(tn >= t0 / 4 && tn <= t0.saturating_mul(4));
|
||||
// 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);
|
||||
assert_eq!(work_of(1 << 63), 2);
|
||||
assert_eq!(work_of(0), u128::MAX >> 64);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
184
pool/src/tls.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
3
pool/tests/fixtures/miner.json
vendored
3
pool/tests/fixtures/miner.json
vendored
|
|
@ -322,5 +322,6 @@
|
|||
"shift": null,
|
||||
"stale": 0
|
||||
}
|
||||
]
|
||||
],
|
||||
"history": []
|
||||
}
|
||||
13
pool/tests/fixtures/stats.json
vendored
13
pool/tests/fixtures/stats.json
vendored
|
|
@ -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"
|
||||
}
|
||||
203
pool/tools/open-gate.mjs
Normal file
203
pool/tools/open-gate.mjs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
#!/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';
|
||||
|
||||
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;
|
||||
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); });
|
||||
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);
|
||||
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' };
|
||||
|
||||
// 1. the nodes, a ring
|
||||
const nodePorts = (n) => ({ grpc: BASE + n * 4, p2p: BASE + n * 4 + 1, json: BASE + n * 4 + 2, evm: BASE + n * 4 + 3 });
|
||||
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(`--connect=127.0.0.1:${nodePorts(n - 1).p2p}`); else a.push('--outpeers=0');
|
||||
run(`node${n}`, NODE, a, FT);
|
||||
}
|
||||
for (let i = 0; i < 30; i++) { await sleep(1000); if (spawnSync(MINER, ['watch', '1', `grpc://127.0.0.1:${BASE}`], { timeout: 15000 }).status === 0) break; }
|
||||
log(`${NODES} nodes up`);
|
||||
|
||||
// 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'];
|
||||
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 {} }
|
||||
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] = BigInt(await evm('eth_getBalance', [a, 'latest']));
|
||||
|
||||
// 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));
|
||||
// first shares per member, from the daemons' share lists (their addresses)
|
||||
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) firstShareMs[s.address] = s.timestamp;
|
||||
}
|
||||
const paid = [];
|
||||
for (const a of ADDR) { const b = BigInt(await evm('eth_getBalance', [a, 'latest'])); if (b > balance0[a] && firstPaidMs[a] == null) firstPaidMs[a] = Date.now(); paid.push(b - balance0[a]); }
|
||||
const nPaid = paid.filter(x => x > 0n).length;
|
||||
samples.push({ t: Math.round((Date.now() - t0) / 1000), 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 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
|
||||
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] = BigInt(await evm('eth_getBalance', [a, 'latest'])) - 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;
|
||||
// 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 blocks = (await api(0, '/api/blocks?limit=1000')).blocks.filter(b => b.status === 'confirmed');
|
||||
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,
|
||||
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,
|
||||
stale_rate: (() => { const s = opens.reduce((x, o) => x + o.stale, 0), a = opens.reduce((x, o) => x + o.accepted, 0); return a ? +(s / a).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 && 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);
|
||||
|
|
@ -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/<address></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/<address></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,'&').replace(/</g,'<');
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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’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’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’s split.</p>
|
||||
<div class="tbl"><table style="min-width:0">
|
||||
<thead><tr><th>Where</th><th>Off with</th></tr></thead>
|
||||
<tbody>
|
||||
|
|
|
|||
Loading…
Reference in a new issue