igneum/pool/src/sidechain.rs

700 lines
34 KiB
Rust

//! 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<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). 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<u64> {
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::<u128>() / 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::<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;
}
};
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::<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 (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) * 2 && eased <= (1u64 << 58) * 16, "slow shares ease the target, within bounds: {eased:x}");
// no compounding: no share's target is more than four times its parent's
let chain_now = slow.ancestry(&p, 64);
for w in chain_now.windows(2) {
assert!(w[0].target() <= w[1].target().saturating_mul(4), "a share eased more than four times its parent: {:x} after {:x}", w[0].target(), w[1].target());
}
}
// 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);
}
}