igneum/pool/src/protocol.rs
igneum-labs 633e211c91 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 3e5271ba6a)
2026-10-07 14:03:05 +00:00

271 lines
8.6 KiB
Rust

//! The pool protocol on the wire: spec 09 section 9.5, one JSON object per line, `"t"` names the type.
//!
//! v0 encoding notes (docs/plans/pool.md): a `template` carries the node's own `RpcRawBlock` JSON (mode A, the full
//! block, so a member with a node can submit it there as well); `authorize` carries the member's payout address and
//! worker name beside the key, because v0 pays by EVM address and the spec's message names the member by key only;
//! `job` carries the seed pair the worker protocol wants (`epoch_seed`, `day`) and the program class and era seed
//! (Counter ASIC 2.0) so a member needs no `seeds` lookup
//! per job. Unknown fields are ignored, as the spec requires.
use kaspa_rpc_core::RpcRawBlock;
use serde::{Deserialize, Serialize};
/// Protocol version string sent in `hello.versions` and `welcome.version`.
pub const VERSION: &str = "0.1";
/// Largest line accepted on either side (spec 9.3: 4 MiB, a template with its transactions).
pub const MAX_LINE: usize = 4 << 20;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Msg {
Hello {
versions: Vec<String>,
chain_id: u64,
client: String,
#[serde(default)]
modes: Vec<String>,
},
Welcome {
version: String,
chain_id: u64,
network: String,
pool_address: String,
modes: Vec<String>,
vote_mode: String,
share_scheme: ShareScheme,
min_shift: u32,
max_shift: u32,
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
pubkey: String,
/// 96 bytes hex, the proof of possession (spec 3.10 KeyReveal)
pop: String,
/// Worker name shown on the dashboards
label: String,
/// EVM payout address, 0x + 40 hex (v0 addition)
payout: String,
/// 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,
},
Authorized {
member_id: u64,
revealed: bool,
key_hash: String,
},
Seeds {
epoch_seed: String,
day: u64,
day_seed: String,
seed_source: String,
next_epoch_seed: Option<String>,
next_at_daa: u64,
epoch_blocks: u64,
epoch_lead: u64,
day_ms: u64,
/// Counter ASIC 2.0 (added 6 October 2026, after the fleet's 0-share run): the program class of the current
/// epoch as the node's `pow_epoch.program_class` reports it (the generator number, 2 or 3), the next epoch's
/// class, and the era seed class v3 programs are drawn under. A member hashes the program these name.
program_class: u32,
next_program_class: u32,
era_seed: Option<String>,
era_index: u64,
/// The node's genesis day index, genesis dataset size (log2 words) and class v3 activation DAA: what a
/// member without a verifier node installs so its day cache is built at the network's size
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,
share_target64: String,
},
Template {
template_id: u64,
mode: String,
block: Box<RpcRawBlock>,
daa_score: u64,
bits: u32,
#[serde(default)]
votes_carried: Vec<u64>,
},
Job {
job_id: u64,
template_id: u64,
prehash: String,
target64: String,
share_target64: String,
nonce_start: String,
nonce_count: String,
epoch_seed: String,
day: u64,
/// The program class (generator number) and era seed of the job's epoch: the member's CPU re-check and its
/// 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 {
job_id: u64,
code: String,
detail: String,
},
Share {
job_id: u64,
nonce: String,
hash: String,
},
ShareResult {
job_id: u64,
nonce: String,
accepted: bool,
code: String,
weight: f64,
#[serde(default)]
block: bool,
},
Solution {
job_id: u64,
nonce: String,
hash: String,
block_hash: String,
},
Stats {
#[serde(default)]
hashrate: f64,
#[serde(default)]
workers: u32,
#[serde(default)]
refusals: u64,
#[serde(default)]
shares: u64,
#[serde(default)]
members: u32,
#[serde(default)]
blocks_24h: u64,
#[serde(default)]
declared_share: f64,
},
Ping {
id: u64,
},
Pong {
id: u64,
},
Bye {
reason: String,
},
Error {
code: String,
detail: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShareScheme {
pub scheme: String,
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 {
/// One line, newline terminated.
pub fn line(&self) -> String {
let mut s = serde_json::to_string(self).expect("message serialises");
s.push('\n');
s
}
pub fn parse(line: &str) -> Result<Msg, String> {
serde_json::from_str(line).map_err(|e| e.to_string())
}
}
pub fn hex_u64(v: u64) -> String {
format!("{v:016x}")
}
pub fn parse_hex_u64(s: &str) -> Option<u64> {
u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()
}
pub fn hex_bytes(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub fn parse_hex_array<const N: usize>(s: &str) -> Option<[u8; N]> {
let s = s.trim_start_matches("0x");
if s.len() != 2 * N {
return None;
}
let mut out = [0u8; N];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
out[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn messages_round_trip_with_the_type_tag() {
let m = Msg::Share { job_id: 7, nonce: hex_u64(0x1234), hash: hex_u64(42) };
let line = m.line();
assert!(line.starts_with("{\"t\":\"share\""), "{line}");
assert!(line.ends_with('\n'));
match Msg::parse(line.trim()).unwrap() {
Msg::Share { job_id, nonce, hash } => {
assert_eq!(job_id, 7);
assert_eq!(parse_hex_u64(&nonce), Some(0x1234));
assert_eq!(parse_hex_u64(&hash), Some(42));
}
other => panic!("{other:?}"),
}
}
#[test]
fn unknown_fields_are_ignored_and_missing_optionals_default() {
let m = Msg::parse(r#"{"t":"hello","versions":["0.1"],"chain_id":4463,"client":"x","extra":1}"#).unwrap();
assert!(matches!(m, Msg::Hello { modes, .. } if modes.is_empty()));
assert!(Msg::parse(r#"{"t":"nope"}"#).is_err());
}
#[test]
fn hex_helpers() {
assert_eq!(parse_hex_array::<2>("0a0b"), Some([10, 11]));
assert_eq!(parse_hex_array::<2>("0a0"), None);
assert_eq!(hex_bytes(&[255, 0]), "ff00");
}
}