From a4c9958cc8e6191bcc6c1937c3a321dfb0662940 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:45:41 +0000 Subject: [PATCH] Open pool: the share's claimed key, its header's key and its coinbase reveal are one key or the share is refused before its PoW (sidechain::check_key, called in accept; known-pass make_share, known-fail the claim, the header and a foreign reveal); suite 30 of 30 on build-2 Co-Authored-By: Claude Fable 5.1 --- pool/src/open.rs | 4 +++- pool/src/sidechain.rs | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/pool/src/open.rs b/pool/src/open.rs index e8e630210..4c44571ce 100644 --- a/pool/src/open.rs +++ b/pool/src/open.rs @@ -10,7 +10,7 @@ //! fee-neutral and the choice is about variance alone (reinvent 3.5). use crate::pool::{Member, Pool}; -use crate::sidechain::{check_pow, ChainParams, SeedsWire, Share, ShareChain, GENESIS_PARENT}; +use crate::sidechain::{check_key, check_pow, ChainParams, SeedsWire, Share, ShareChain, GENESIS_PARENT}; use crate::state::{unix_ms, BlockRec, State}; use crate::verify::JobKey; use kaspa_consensus_core::block::Block; @@ -318,6 +318,8 @@ impl Open { if wire != seeds { return Err(format!("the share's day {} is not the day of its timestamp ({})", wire.day, seeds.day)); } + // Igneum 2.0: the claimed key, the header's key and the reveal are one key, before the PoW + crate::sidechain::check_key(&share)?; check_pow(&self.engine, &share)?; let share = Arc::new(share); let tip = { diff --git a/pool/src/sidechain.rs b/pool/src/sidechain.rs index ba24d449b..613d149c8 100644 --- a/pool/src/sidechain.rs +++ b/pool/src/sidechain.rs @@ -436,6 +436,25 @@ impl ShareChain { } /// The PoW of a share: the lane hash under the share's seeds equals the claim and meets the share's target. +/// Igneum 2.0, the vote-key commitment on the open pool (`docs/design/pool-vote-key-commitment.md` 2.2 item 3 and 5.3 +/// item 2): the key a share claims (`key_hash`), the key its header names (`vote_key_hash`, under proof of work) and, +/// when its coinbase carries a reveal (`IGNK`), the reveal's key are one key, or the share is refused before its PoW is +/// evaluated. A daemon that stamps another identity into a member's template fails here on every honest daemon. +pub fn check_key(s: &Share) -> Result<(), String> { + let claimed: Hash = s.key_hash.parse().map_err(|_| format!("key_hash {} is not a hash", s.key_hash))?; + let header_key = s.raw.header.vote_key_hash; + if claimed != header_key { + return Err(format!("the share claims key {} but its header names {header_key}", s.key_hash)); + } + if let Some(cb) = s.raw.transactions.first() + && let Some(reveal) = KeyReveal::find_in(&cb.payload) + && reveal.key_hash() != header_key + { + return Err(format!("the coinbase reveals key {} but the header names {header_key}", reveal.key_hash())); + } + Ok(()) +} + 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}"))?; @@ -583,6 +602,36 @@ pub mod tests { } } + /// Igneum 2.0: the key a share claims, the key its header names and the key its coinbase reveals are one key. + /// Known-pass: make_share. Known-fail: the claim swapped, the header's key swapped (the PoW still over the old + /// header, so check_key fires before check_pow would), and a foreign reveal in the coinbase. + #[test] + fn a_share_whose_keys_disagree_is_refused_before_its_pow() { + let engine = IgneumEngine::new(); + let mut chain = ShareChain::new(params()); + chain.genesis_target = Some(u64::MAX >> 6); + let good = make_share(&chain, &engine, GENESIS_PARENT, "ma", [0xaa; 20], 1_000_000, 0); + assert!(check_key(&good).is_ok(), "known-pass"); + let mut claim = good.clone(); + claim.key_hash = Hash::from_bytes([9u8; 32]).to_string(); + assert!(check_key(&claim).unwrap_err().contains("claims key"), "known-fail: the claim"); + let mut header = good.clone(); + header.raw.header.vote_key_hash = Hash::from_bytes([9u8; 32]); + assert!(check_key(&header).unwrap_err().contains("header names"), "known-fail: the header"); + let mut reveal = good.clone(); + let other = kaspa_consensus_core::finality::VoteSecretKey::from_label("mb").key_reveal(); + let mut payload = reveal.raw.transactions[0].payload.clone(); + // the reveal in the payload is IGNK || pubkey || pop (scheme 0): overwrite it in place with the other key's + let tag = kaspa_consensus_core::finality::KEY_REVEAL_TAG; + let at = payload.windows(4).position(|w| w == tag).expect("the test share carries a reveal"); + let mine = good.raw.transactions[0].payload[at..].to_vec(); + let theirs = other.to_extra_data(); + assert_eq!(mine.len() >= theirs.len(), true); + payload[at..at + theirs.len()].copy_from_slice(&theirs); + reveal.raw.transactions[0].payload = payload; + assert!(check_key(&reveal).unwrap_err().contains("reveals key"), "known-fail: a foreign reveal"); + } + /// 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.