igneum/pool/src/protocol.rs
igneum-labs c6b0f5edbf Pool v0 rebased onto master and the 0.3.14 fork: the program class and era seed ride with every seeds and job line; the re-check test for class v3 and class v4
The fleet's 10-member load run (6 October 2026, 18:14Z to 18:24Z) accepted 0 shares: the pool fork's miner re-checked GPU
shares with a fixed class v2 program while the 0.3.14 workers hashed class v3 (docs/plans/pool.md section 9).

pool: node.rs builds the share verifier's seeds from the template's pow_epoch with the class and the era, installs the
node's genesis day, dataset size and class v3 activation beside the schedule, and sends program_class, next_program_class,
era_seed, era_index, genesis_day_index, genesis_dataset_log2 and program_class_v3_activation_daa in `seeds` and
program_class and era_seed in `job` (protocol.rs, additive fields); verify.rs tests through EpochSeeds::v2. Protocol,
vardiff, PPLNS, stats API and page otherwise unchanged. Suite 22 of 22 on igneum-build-1.

igneum-pow tests/recheck.rs: for class v3 (packs-ca2-mixer/mx8-devnet-epoch0) and class v4 (packs-ca3-v4/v4-devnet-epoch0)
the pack's program reproduces the pack's 96 vectors (the worker's reference), the chain seam the node engine calls
(Epoch::chain_program, chain_dataset_day) builds the same program, one known nonce hashes equal through both paths, and
the fixed-v2 program of the same seed disagrees; the pinned v3 and v4 program ids differ. 2 of 2 on igneum-build-1.

packaging/hive: the pool:// mode merged with master's per-card IDENTITIES=auto and OVERRIDE handling (selftest passes).
pool/README.md and pool.md: building on igneum-build-1 (the vendor/igneum-node symlink on the box).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:49:13 +00:00

245 lines
7.2 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,
},
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,
/// `binding` of spec 9.3 (a signature over the TLS exporter) is absent in v0: no TLS yet
#[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,
},
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>,
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,
}
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");
}
}