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