//! 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, chain_id: u64, client: String, #[serde(default)] modes: Vec, }, Welcome { version: String, chain_id: u64, network: String, pool_address: String, modes: Vec, 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, 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, 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, daa_score: u64, bits: u32, #[serde(default)] votes_carried: Vec, }, 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, 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 { 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::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(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"); } }