Open pool: the seeds check accepts another node's view of an epoch when its seed is a block the member's node holds at the seed depth (a DAG settling below the lead), the node's epochs kept by span; the gate harness starts the nodes in sequence with --addpeer and reads their peers and DAA scores by RPC before and during the run (the first box run was three DAG partitions)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 12:56:22 +00:00
parent 093f052c7e
commit 953bf4a239
6 changed files with 149 additions and 27 deletions

View file

@ -397,7 +397,7 @@ dozen hashes of work rounded the dev entry to a third of its share: the unit tes
| `igneum/exec/src/executor.rs`, `service.rs` (fork) | `SegmentBlock.split`; the reward loop pays by the split at or above the switch; the test with an odd subsidy (the dust) | | `igneum/exec/src/executor.rs`, `service.rs` (fork) | `SegmentBlock.split`; the reward loop pays by the split at or above the switch; the test with an odd subsidy (the dust) |
| `kaspad/src/daemon.rs` (fork) | installs the switch from the params beside the fees | | `kaspad/src/daemon.rs` (fork) | installs the switch from the params beside the fees |
| `pool/src/sidechain.rs` | the share, the chain, the target, the window's split, fork choice, `verify-share` | | `pool/src/sidechain.rs` | the share, the chain, the target, the window's split, fork choice, `verify-share` |
| `pool/src/open.rs` | the daemon side: stamping, accepting (structure, the node's seeds, the PoW), the shares log, orphans waiting for a parent, the API | | `pool/src/open.rs` | the daemon side: stamping, accepting (structure, the seeds, the PoW), the shares log, orphans waiting for a parent, the API. The seeds check cost two gate runs on the box: the first keyed the node's epochs by `DAA / epoch_blocks` (not how the node numbers them: every share past epoch 1 refused), the second required the daemon's own node's seed exactly, and four nodes on the 60x profile named three different seed blocks for epoch 1 with no node refusing any block (a node checks a block's PoW under the seed of the block's own ancestry); the rule now is the node's seed, or a block the node holds at the epoch's seed depth with the epoch's class, era and rung |
| `pool/src/p2p.rs` | the gossip: hello, share, get_shares, the relay, the sync when a peer is ahead | | `pool/src/p2p.rs` | the gossip: hello, share, get_shares, the relay, the sync when a peer is ahead |
| `pool/tools/open-gate.mjs` | the gate | | `pool/tools/open-gate.mjs` | the gate |
@ -414,7 +414,11 @@ binaries (the standing rule of 7 October 2026 afternoon: nothing builds or runs
the Mac at load 113 and the Mac crashed under it, section 10.4's retarget note): 4 nodes on the 60x fast-time profile the Mac at load 113 and the Mac crashed under it, section 10.4's retarget note): 4 nodes on the 60x fast-time profile
with real proof of work at `genesis_bits 0x1e400000`, the genesis dataset at 2^24 words (64 MiB per process against the with real proof of work at `genesis_bits 0x1e400000`, the genesis dataset at 2^24 words (64 MiB per process against the
devnet's 1 GiB, so 100 CPU members, 10 daemons and 4 nodes fit 64 GB), `pool_split_activation_daa 0`; 10 open daemons devnet's 1 GiB, so 100 CPU members, 10 daemons and 4 nodes fit 64 GB), `pool_split_activation_daa 0`; 10 open daemons
(one per node in turn, peered in a ring with two chords, the last one withholding its members' shares); 100 CPU members (one per node in turn, peered in a ring with two chords, the last one withholding its members' shares); the nodes
started one after another with `--addpeer` and their peer counts read by `getConnectedPeerInfo` before anything else
starts (the first box run started four nodes at once with `--connect`, nodes 2 and 3 never peered, and the one DAG was
three partitions with three epoch seeds, which the share chain's seeds check then reported as "not the node's": the
harness verifies the chain-side fact now, the standing rule); 100 CPU members
(one key and one address each, one thread each, niced); the chain at 0.1 s per share (at 1 block a second a 10-second (one key and one address each, one thread each, niced); the chain at 0.1 s per share (at 1 block a second a 10-second
chain is sub-block work only for a pool under a tenth of the network, spec 9.12 item 8), window 2,160. chain is sub-block work only for a pool under a tenth of the network, spec 9.12 item 8), window 2,160.

View file

@ -160,7 +160,7 @@ Added 7 October 2026 (mission item 11; docs/plans/pool.md section 10). Lineage:
1. A member of the open pool runs its own node and the open daemon beside it (`igneum-pool --open`). The daemon serves this section's member protocol to the member's own miners, builds every template from the member's node with the member's key in the header and the member's own payout address in the coinbase (`IGNA`), and holds no key and no balance. 1. A member of the open pool runs its own node and the open daemon beside it (`igneum-pool --open`). The daemon serves this section's member protocol to the member's own miners, builds every template from the member's node with the member's key in the header and the member's own payout address in the coinbase (`IGNA`), and holds no key and no balance.
2. A share is a template at the share chain's target: a lane hash at or below `share_target64` of the chain, with the coinbase extra data carrying, beside the reveal and `IGNA`, the share it extends (`IGNS` \|\| 64 hex: the parent's block hash, the zero hash for a genesis share) and the window's payout split (`IGNP` \|\| 4 hex count \|\| count x (40 hex address \|\| 8 hex weight), `kaspa_consensus_core::evm`). A share's identity is its block hash. A share whose lane hash is also at or below the block target is a block, submitted to the member's node as any block. 2. A share is a template at the share chain's target: a lane hash at or below `share_target64` of the chain, with the coinbase extra data carrying, beside the reveal and `IGNA`, the share it extends (`IGNS` \|\| 64 hex: the parent's block hash, the zero hash for a genesis share) and the window's payout split (`IGNP` \|\| 4 hex count \|\| count x (40 hex address \|\| 8 hex weight), `kaspa_consensus_core::evm`). A share's identity is its block hash. A share whose lane hash is also at or below the block target is a block, submitted to the member's node as any block.
3. The chain. One parent per share; the heaviest cumulative work (the sum of `2^64 / target` over the ancestry) is the tip; a share off the tip's ancestry is stale and pays nothing; a share extending more than 256 heights below the tip is refused. Target: `chain_share_s` seconds per share (10 by default), held by the Kaspa DAA's shape over the last 30 shares (the window's mean target times its actual span over its expected span, header timestamps), clamped to a factor of 4 against the parent's target, never above 2^62 nor ten doublings above the chain's first target; the first target is a chain constant (`--chain-genesis-target64`, by default the network's genesis block target eight times easier; never a node's current template, which differs between members). Members gossip shares (`p2p.rs`: `hello`, `share`, `get_shares`, newline JSON); every share is checked by every member: the parent known, the height, the target the chain fixes after the parent, the block hash and the body's merkle root, the reveal's key in the header, `IGNA` the share's address, `IGNS` the parent, `IGNP` byte-equal to the split the chain computes for that parent and that address, the seeds those of the member's own node for the share's DAA score, and the PoW under those seeds. Nodes never see a share. 3. The chain. One parent per share; the heaviest cumulative work (the sum of `2^64 / target` over the ancestry) is the tip; a share off the tip's ancestry is stale and pays nothing; a share extending more than 256 heights below the tip is refused. Target: `chain_share_s` seconds per share (10 by default), held by the Kaspa DAA's shape over the last 30 shares (the window's mean target times its actual span over its expected span, header timestamps), clamped to a factor of 4 against the parent's target, never above 2^62 nor ten doublings above the chain's first target; the first target is a chain constant (`--chain-genesis-target64`, by default the network's genesis block target eight times easier; never a node's current template, which differs between members). Members gossip shares (`p2p.rs`: `hello`, `share`, `get_shares`, newline JSON); every share is checked by every member: the parent known, the height, the target the chain fixes after the parent, the block hash and the body's merkle root, the reveal's key in the header, `IGNA` the share's address, `IGNS` the parent, `IGNP` byte-equal to the split the chain computes for that parent and that address, the seeds either those the member's own node reported for the share's epoch or (another node's view of the same epoch, which a DAG allows while the chain below the lead settles) an epoch seed that is a block the member's node holds at that epoch's seed depth with the epoch's class, era and rung, and the PoW under those seeds. Nodes never see a share.
4. The split. For a share by address A at target T extending parent P: the last `window_shares - 1` shares ending at P plus the share itself, weighed by `2^64 / target`, summed per address; the software dev fee as one more entry at `open_dev_fee_percent` of the whole (the same 1 percent the solo miner pays, so solo, pool-0 and the open pool are fee-neutral); scaled to u32 weights of 2^32; descending by weight then ascending by address; at most 256 entries (the smallest dropped past it). Every member of a chain holds the same `window_shares`, `chain_share_s`, fee and dev address, or its shares mismatch on the split and are refused: these four are the chain's constants, named by its `--open-chain` string. 4. The split. For a share by address A at target T extending parent P: the last `window_shares - 1` shares ending at P plus the share itself, weighed by `2^64 / target`, summed per address; the software dev fee as one more entry at `open_dev_fee_percent` of the whole (the same 1 percent the solo miner pays, so solo, pool-0 and the open pool are fee-neutral); scaled to u32 weights of 2^32; descending by weight then ascending by address; at most 256 entries (the smallest dropped past it). Every member of a chain holds the same `window_shares`, `chain_share_s`, fee and dev address, or its shares mismatch on the split and are refused: these four are the chain's constants, named by its `--open-chain` string.
5. Payment (the consensus-visible rule, `igneum/exec/src/executor.rs`): from `pool_split_activation_daa` on (a params field, in the digest once set, never by default; the fast-time gate ran it at 0), a blue block whose coinbase carries a well-formed `IGNP` has its producer share paid by the split: each entry `floor(share x weight / sum)`, the rounding dust to the block's `IGNA` address; a block without a split, below the switch, or with a malformed split pays its `IGNA` address alone, as every block does today. The 20 percent proving share is untouched. So a block found on the share chain pays the window from its own coinbase, every node computes the same credits from the block alone, and nobody holds anything for anybody. 5. Payment (the consensus-visible rule, `igneum/exec/src/executor.rs`): from `pool_split_activation_daa` on (a params field, in the digest once set, never by default; the fast-time gate ran it at 0), a blue block whose coinbase carries a well-formed `IGNP` has its producer share paid by the split: each entry `floor(share x weight / sum)`, the rounding dust to the block's `IGNA` address; a block without a split, below the switch, or with a malformed split pays its `IGNA` address alone, as every block does today. The 20 percent proving share is untouched. So a block found on the share chain pays the window from its own coinbase, every node computes the same credits from the block alone, and nobody holds anything for anybody.
6. What a withheld share earns: nothing. A share kept from the gossip is in no other member's chain, so no block but the withholder's own names it in a split, and the withholder's own blocks pay the others' shares by the same rule. Dropping another member's shares is the same act and costs the dropper the same. 6. What a withheld share earns: nothing. A share kept from the gossip is in no other member's chain, so no block but the withholder's own names it in a split, and the withholder's own blocks pay the others' shares by the same rule. Dropping another member's shares is the same act and costs the dropper the same.

View file

@ -162,6 +162,7 @@ async fn main() {
}; };
let state_path = cfg.data_dir.join("state.json"); let state_path = cfg.data_dir.join("state.json");
let engine = Arc::new(IgneumEngine::new()); let engine = Arc::new(IgneumEngine::new());
let walker_for_open = walker.clone();
let pool = Arc::new(Pool { let pool = Arc::new(Pool {
engine: engine.clone(), engine: engine.clone(),
state: Mutex::new(State::load(&state_path, cfg.pplns_window_blocks)), state: Mutex::new(State::load(&state_path, cfg.pplns_window_blocks)),
@ -183,7 +184,7 @@ async fn main() {
template_failures: AtomicU64::new(0), template_failures: AtomicU64::new(0),
last_template_ok_ms: AtomicU64::new(0), last_template_ok_ms: AtomicU64::new(0),
tls, tls,
open: cfg.open.then(|| Arc::new(open::Open::new(&cfg, &state_path, engine.clone()))), open: cfg.open.then(|| Arc::new(open::Open::new(&cfg, &state_path, engine.clone(), walker_for_open))),
}); });
let chain = evm.call("eth_chainId", serde_json::json!([])).await.ok().and_then(|v| v.as_str().map(|s| s.to_string())); let chain = evm.call("eth_chainId", serde_json::json!([])).await.ok().and_then(|v| v.as_str().map(|s| s.to_string()));
println!( println!(

View file

@ -15,8 +15,10 @@ use crate::state::{unix_ms, BlockRec, State};
use crate::verify::JobKey; use crate::verify::JobKey;
use kaspa_consensus_core::block::Block; use kaspa_consensus_core::block::Block;
use kaspa_consensus_core::evm::payout_split_in; use kaspa_consensus_core::evm::payout_split_in;
use kaspa_consensus_core::igneum::{pow_epoch_blocks, ProgramClass}; use kaspa_consensus_core::igneum::{pow_epoch_blocks, pow_schedule, ProgramClass};
use kaspa_grpc_client::GrpcClient;
use kaspa_hashes::Hash; use kaspa_hashes::Hash;
use kaspa_rpc_core::api::rpc::RpcApi;
use kaspa_pow::igneum::{day_index, target64, EpochSeeds, IgneumEngine}; use kaspa_pow::igneum::{day_index, target64, EpochSeeds, IgneumEngine};
use kaspa_rpc_core::{RpcPowEpochInfo, RpcRawBlock}; use kaspa_rpc_core::{RpcPowEpochInfo, RpcRawBlock};
use std::collections::HashMap; use std::collections::HashMap;
@ -47,14 +49,32 @@ pub fn genesis_target_for(cfg: &crate::config::Config) -> u64 {
t.checked_shl(GENESIS_TARGET_SHIFT).filter(|x| (*x >> GENESIS_TARGET_SHIFT) == t).unwrap_or(u64::MAX >> 1).min(crate::sidechain::MAX_TARGET) t.checked_shl(GENESIS_TARGET_SHIFT).filter(|x| (*x >> GENESIS_TARGET_SHIFT) == t).unwrap_or(u64::MAX >> 1).min(crate::sidechain::MAX_TARGET)
} }
#[derive(Clone, Copy, Debug)]
struct EpochRec {
index: u64,
start_daa: u64,
end_daa: u64,
epoch: Hash,
class: ProgramClass,
era: Hash,
reps: u16,
}
pub struct Open { pub struct Open {
pub chain: Mutex<ShareChain>, pub chain: Mutex<ShareChain>,
pub chain_name: String, pub chain_name: String,
pub dev_fee_percent: u32, pub dev_fee_percent: u32,
pub engine: Arc<IgneumEngine>, pub engine: Arc<IgneumEngine>,
/// Epoch index to (epoch seed, class, era seed, ladder rung), from the node's templates: what a received share's /// The epochs this daemon's node has reported, from its templates: (epoch index, first DAA score, boundary DAA
/// seeds are checked against (a peer's share names its seeds; the node's are the truth) /// score, epoch seed, class, era seed, ladder rung). A received share names its seeds; they must be one of these,
epochs: Mutex<HashMap<u64, (Hash, ProgramClass, Hash, u16)>>, /// and the share's DAA score must sit within an epoch of that one's span (a member on another node a few DAA
/// scores ahead or behind at a boundary is still that epoch's share)
epochs: Mutex<Vec<EpochRec>>,
/// The member's node (the pool's walker connection): a share naming an epoch seed this node's templates did not
/// is checked against the node's own blocks (the seed is a block hash at the epoch's seed depth)
node: Arc<GrpcClient>,
/// Seed block hash to its DAA score, as the node answered (bounded)
seed_blocks: Mutex<HashMap<Hash, Option<u64>>>,
/// Shares whose parent is not known yet, by parent, waiting for it (bounded) /// Shares whose parent is not known yet, by parent, waiting for it (bounded)
orphans: Mutex<HashMap<[u8; 32], Vec<Share>>>, orphans: Mutex<HashMap<[u8; 32], Vec<Share>>>,
/// New shares for the gossip: (share, origin connection id; 0 = this daemon's own) /// New shares for the gossip: (share, origin connection id; 0 = this daemon's own)
@ -74,7 +94,7 @@ pub struct Open {
} }
impl Open { impl Open {
pub fn new(cfg: &crate::config::Config, state_path: &Path, engine: Arc<IgneumEngine>) -> Self { pub fn new(cfg: &crate::config::Config, state_path: &Path, engine: Arc<IgneumEngine>, node: Arc<GrpcClient>) -> Self {
let dev = if cfg.network == "devnet" { DEV_FEE_ADDRESS_DEVNET } else { DEV_FEE_ADDRESS }; let dev = if cfg.network == "devnet" { DEV_FEE_ADDRESS_DEVNET } else { DEV_FEE_ADDRESS };
let dev_address = crate::protocol::parse_hex_array::<20>(dev).expect("dev fee address"); let dev_address = crate::protocol::parse_hex_array::<20>(dev).expect("dev fee address");
let params = ChainParams { name: cfg.open_chain.clone(), share_ms: (cfg.chain_share_s * 1000.0).max(100.0) as u64, window: cfg.window_shares, dev_fee_percent: cfg.open_dev_fee_percent, dev_address }; let params = ChainParams { name: cfg.open_chain.clone(), share_ms: (cfg.chain_share_s * 1000.0).max(100.0) as u64, window: cfg.window_shares, dev_fee_percent: cfg.open_dev_fee_percent, dev_address };
@ -88,7 +108,9 @@ impl Open {
chain_name: cfg.open_chain.clone(), chain_name: cfg.open_chain.clone(),
dev_fee_percent: cfg.open_dev_fee_percent, dev_fee_percent: cfg.open_dev_fee_percent,
engine, engine,
epochs: Mutex::new(HashMap::new()), epochs: Mutex::new(Vec::new()),
node,
seed_blocks: Mutex::new(HashMap::new()),
orphans: Mutex::new(HashMap::new()), orphans: Mutex::new(HashMap::new()),
gossip, gossip,
tip_changed: tokio::sync::Notify::new(), tip_changed: tokio::sync::Notify::new(),
@ -132,23 +154,96 @@ impl Open {
/// Remembers the current and the next epoch's seeds from a template, for checking peers' shares. /// Remembers the current and the next epoch's seeds from a template, for checking peers' shares.
pub fn note_epoch(&self, info: &RpcPowEpochInfo, seeds: &EpochSeeds) { pub fn note_epoch(&self, info: &RpcPowEpochInfo, seeds: &EpochSeeds) {
let mut e = self.epochs.lock().unwrap(); let mut e = self.epochs.lock().unwrap();
e.insert(info.epoch_index, (seeds.epoch, seeds.class, seeds.era, seeds.shadow_reps)); let blocks = info.epoch_blocks.max(1);
let mut put = |r: EpochRec| {
if let Some(x) = e.iter_mut().find(|x| x.index == r.index) {
*x = r;
} else {
e.push(r);
}
};
put(EpochRec { index: info.epoch_index, start_daa: info.boundary_daa_score.saturating_sub(blocks), end_daa: info.boundary_daa_score, epoch: seeds.epoch, class: seeds.class, era: seeds.era, reps: seeds.shadow_reps });
if let Some(next) = info.next_epoch_seed { if let Some(next) = info.next_epoch_seed {
e.insert(info.epoch_index + 1, (next, info.next_class(), seeds.era, info.next_latency_ladder_reps as u16)); put(EpochRec { index: info.epoch_index + 1, start_daa: info.boundary_daa_score, end_daa: info.boundary_daa_score + blocks, epoch: next, class: info.next_class(), era: seeds.era, reps: info.next_latency_ladder_reps as u16 });
} }
if e.len() > 64 { if e.len() > 64 {
let min = info.epoch_index.saturating_sub(48); let min = info.epoch_index.saturating_sub(48);
e.retain(|k, _| *k >= min); e.retain(|r| r.index >= min);
} }
} }
/// The seeds a share must have hashed under, from the node's epochs; a share of an epoch this daemon has not /// Checks a share's named seeds. The epoch seed is a block hash (the devnet rule: the last selected-chain block
/// seen is refused (it cannot be checked), which bounds a late joiner's sync to the epochs its node reports. /// below the epoch's start less the lead), and on a DAG two nodes can name two seed blocks for one epoch while the
/// chain below the lead is still settling (the 100-member gate on the 60x profile, 7 October 2026: four nodes,
/// three seeds for epoch 1, no block refused by any node, because a node checks a block's PoW under the seed of
/// the block's own ancestry). So a share's seeds are accepted when they are what this daemon's node reported for
/// the share's epoch (the fast path), or when the named seed is a block this daemon's node holds at that epoch's
/// seed depth (within one epoch below the epoch's start less the lead) with the epoch's class, era and rung: the
/// freedom a share has is the freedom a block producer has, a handful of tips at one depth, never a chosen program.
/// A share of an epoch this daemon's node has not reported at all is refused (O-9.11). The first version keyed the
/// node's epochs by `DAA / epoch_blocks`, which is not how the node numbers them, and refused every share past
/// epoch 1; the second required the node's own seed exactly, and refused every share from another node's view.
fn seeds_for(&self, s: &Share) -> Result<EpochSeeds, String> { fn seeds_for(&self, s: &Share) -> Result<EpochSeeds, String> {
let idx = s.daa_score / pow_epoch_blocks().max(1); let wire = s.seeds.to_seeds()?;
let e = self.epochs.lock().unwrap(); let blocks = pow_epoch_blocks().max(1);
let (epoch, class, era, reps) = e.get(&idx).copied().ok_or_else(|| format!("epoch {idx} (DAA {}) is not one this daemon's node has reported", s.daa_score))?; let lead = pow_schedule().epoch_lead;
Ok(EpochSeeds { epoch, day: day_index(s.timestamp), class, era, shadow_reps: reps }) let (known, covering) = {
let e = self.epochs.lock().unwrap();
let known = e.iter().find(|r| r.epoch == wire.epoch && r.class == wire.class && r.era == wire.era && r.reps == wire.shadow_reps).copied();
let covering = e.iter().filter(|r| s.daa_score >= r.start_daa && s.daa_score < r.end_daa).copied().collect::<Vec<_>>();
(known, covering)
};
if let Some(r) = known {
if s.daa_score + blocks < r.start_daa || s.daa_score > r.end_daa + blocks {
return Err(format!("the share's DAA score {} is outside epoch {}'s span {}..{} (within an epoch either side) for the seeds it names", s.daa_score, r.index, r.start_daa, r.end_daa));
}
return Ok(EpochSeeds { epoch: r.epoch, day: day_index(s.timestamp), class: r.class, era: r.era, shadow_reps: r.reps });
}
let Some(r) = covering.first().copied() else {
return Err(format!("epoch of DAA {} is not one this daemon's node has reported", s.daa_score));
};
if wire.class != r.class || wire.era != r.era || wire.shadow_reps != r.reps {
return Err(format!("the share names class {} era {} rung {} for epoch {}; the node's are class {} era {} rung {}", wire.class.name(), &s.seeds.era[..16], wire.shadow_reps, r.index, r.class.name(), r.era, r.reps));
}
// another node's view of the same epoch: the seed must be a block this node holds at the seed depth
let seed_daa = {
let cached = self.seed_blocks.lock().unwrap().get(&wire.epoch).copied();
match cached {
Some(v) => v,
None => {
let node = self.node.clone();
let h = wire.epoch;
let r = tokio::runtime::Handle::current().block_on(async move { tokio::time::timeout(std::time::Duration::from_secs(5), node.get_block(h, false)).await });
let v = match r {
Ok(Ok(b)) => Some(b.header.daa_score),
Ok(Err(e)) => {
println!("{} OPEN seed block {} asked of the node: {e}", unix_ms(), &s.seeds.epoch[..16]);
None
}
Err(_) => {
println!("{} OPEN seed block {} asked of the node: timed out after 5 s", unix_ms(), &s.seeds.epoch[..16]);
None
}
};
let mut c = self.seed_blocks.lock().unwrap();
if v.is_some() {
c.insert(wire.epoch, v);
if c.len() > 4096 {
c.clear();
}
}
v
}
}
};
let Some(seed_daa) = seed_daa else {
return Err(format!("the share names epoch seed {} for epoch {}, which is neither the node's seed nor a block the node holds", &s.seeds.epoch[..16], r.index));
};
let seed_ceiling = r.start_daa.saturating_sub(lead);
if seed_daa >= seed_ceiling || seed_daa + blocks + lead < r.start_daa {
return Err(format!("the share names epoch seed {} (a block at DAA {}) for epoch {}, outside the seed depth {}..{}", &s.seeds.epoch[..16], seed_daa, r.index, r.start_daa.saturating_sub(lead + blocks), seed_ceiling));
}
Ok(EpochSeeds { epoch: wire.epoch, day: day_index(s.timestamp), class: r.class, era: r.era, shadow_reps: r.reps })
} }
/// A share of this daemon's own member: logged, chained, gossiped (unless withheld, the gate's test flag). /// A share of this daemon's own member: logged, chained, gossiped (unless withheld, the gate's test flag).
@ -221,7 +316,7 @@ impl Open {
let seeds = self.seeds_for(&share)?; let seeds = self.seeds_for(&share)?;
let wire = share.seeds.to_seeds()?; let wire = share.seeds.to_seeds()?;
if wire != seeds { if wire != seeds {
return Err(format!("the share names seeds (epoch {}, class {}, rung {}) that are not the node's for its DAA score", &share.seeds.epoch[..16], share.seeds.class, share.seeds.reps)); return Err(format!("the share's day {} is not the day of its timestamp ({})", wire.day, seeds.day));
} }
check_pow(&self.engine, &share)?; check_pow(&self.engine, &share)?;
let share = Arc::new(share); let share = Arc::new(share);

View file

@ -638,7 +638,12 @@ pub mod tests {
ts += 100_000; // ten times the 10-second interval ts += 100_000; // ten times the 10-second interval
} }
let eased = slow.target_after(&p).unwrap(); 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}"); 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 // 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 s1 = Arc::new(make_share(&chain, &engine, parent, "ma", a, t, 6));

View file

@ -22,6 +22,7 @@ import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, appendFileSync } from 'node:fs'; import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, appendFileSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const ROOT = join(here, '..', '..'); const ROOT = join(here, '..', '..');
@ -92,18 +93,31 @@ const FT = { IGNEUM_POW_DAY_MS: '1440000' };
// a balance before the execution layer has executed a chain block reads as 0 (the follower answers an error until then) // a balance before the execution layer has executed a chain block reads as 0 (the follower answers an error until then)
const bal = async (a) => { try { return BigInt(await evm('eth_getBalance', [a, 'latest'])); } catch (e) { if (/no chain block/i.test(e.message)) return 0n; throw e; } }; const bal = async (a) => { try { return BigInt(await evm('eth_getBalance', [a, 'latest'])); } catch (e) { if (/no chain block/i.test(e.message)) return 0n; throw e; } };
// 1. the nodes, a ring // 1. the nodes, a chain of peers: each started once the one before answers, each adding the one before as a peer
// (--addpeer retries; a --connect at start raced the earlier node's listener on the box, 7 October 2026: nodes 2 and
// 3 of the first box run never peered, three partitions of one DAG, three epoch seeds, and the share chain was blamed)
const nodePorts = (n) => ({ grpc: BASE + n * 4, p2p: BASE + n * 4 + 1, json: BASE + n * 4 + 2, evm: BASE + n * 4 + 3 }); const nodePorts = (n) => ({ grpc: BASE + n * 4, p2p: BASE + n * 4 + 1, json: BASE + n * 4 + 2, evm: BASE + n * 4 + 3 });
const rpcs = [];
for (let n = 0; n < NODES; n++) { for (let n = 0; n < NODES; n++) {
const p = nodePorts(n); const p = nodePorts(n);
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--appdir=${join(SCRATCH, 'node' + n)}`, `--rpclisten=127.0.0.1:${p.grpc}`, `--rpclisten-json=127.0.0.1:${p.json}`, `--evm-rpclisten=127.0.0.1:${p.evm}`, `--listen=127.0.0.1:${p.p2p}`, `--appdir=${join(SCRATCH, 'node' + n)}`, `--rpclisten=127.0.0.1:${p.grpc}`, `--rpclisten-json=127.0.0.1:${p.json}`, `--evm-rpclisten=127.0.0.1:${p.evm}`, `--listen=127.0.0.1:${p.p2p}`,
`--override-params-file=${override}`, '--loglevel=info', '--yes']; `--override-params-file=${override}`, '--loglevel=info', '--yes'];
if (n > 0) a.push(`--connect=127.0.0.1:${nodePorts(n - 1).p2p}`); else a.push('--outpeers=0'); if (n > 0) a.push(`--addpeer=127.0.0.1:${nodePorts(n - 1).p2p}`); else a.push('--outpeers=0');
run(`node${n}`, NODE, a, FT); run(`node${n}`, NODE, a, FT);
const rpc = await connectRpc(`ws://127.0.0.1:${p.json}`, { attempts: 120, waitMs: 500 });
if (!rpc) { log(`node ${n} answered no RPC in 60 s`); stopAll(); process.exit(2); }
rpcs.push(rpc);
} }
for (let i = 0; i < 30; i++) { await sleep(1000); if (spawnSync(MINER, ['watch', '1', `grpc://127.0.0.1:${BASE}`], { timeout: 15000 }).status === 0) break; } // the chain-side fact, never a process name: every node past the first has a peer, and the DAG is one
log(`${NODES} nodes up`); const peersOf = async (n) => { try { const r = await rpcs[n].call('getConnectedPeerInfo', {}); return (r.peerInfo || r.infos || r.peers || []).length; } catch (e) { return -1; } };
for (let n = 1; n < NODES; n++) {
let ok = false;
for (let i = 0; i < 60 && !ok; i++) { await sleep(1000); ok = (await peersOf(n)) >= 1; }
if (!ok) { log(`node ${n} has no peer after 60 s (getConnectedPeerInfo)`); stopAll(); process.exit(2); }
}
const daaOf = async (n) => { try { return (await rpcs[n].call('getBlockDagInfo', {})).virtualDaaScore; } catch { return null; } };
log(`${NODES} nodes up, peers ${(await Promise.all(Array.from({ length: NODES }, (_, n) => peersOf(n)))).join('/')}`);
// 2. the daemons: each on a node, no payout key, the share chain peered in a ring with two chords // 2. the daemons: each on a node, no payout key, the share chain peered in a ring with two chords
const daemonNode = (d) => d % NODES; const daemonNode = (d) => d % NODES;
@ -162,8 +176,9 @@ while (Date.now() - t0 < SECS * 1000) {
const heights = opens.map(o => o.height); const heights = opens.map(o => o.height);
const tips = new Set(opens.map(o => o.tip)); const tips = new Set(opens.map(o => o.tip));
const nPaid = ADDR.filter(a => firstPaidMs[a] != null).length; const nPaid = ADDR.filter(a => firstPaidMs[a] != null).length;
samples.push({ t: Math.round((Date.now() - t0) / 1000), heights, tips: tips.size, accepted: opens.map(o => o.accepted), stale: opens.map(o => o.stale), reorgs: opens.map(o => o.reorgs), rejected: opens.map(o => o.rejected), blocks: opens.map(o => o.blocks_found), paid_members: nPaid, daa: stats0.network.daa_score, net_hashrate: stats0.network.hashrate }); const daas = await Promise.all(Array.from({ length: NODES }, (_, n) => daaOf(n)));
log(`t=${Math.round((Date.now() - t0) / 1000)}s heights=${Math.min(...heights)}..${Math.max(...heights)} tips=${tips.size} stale=${opens.reduce((s, o) => s + o.stale, 0)} reorgs=${opens.reduce((s, o) => s + o.reorgs, 0)} rejected=${opens.reduce((s, o) => s + o.rejected, 0)} blocks=${opens.reduce((s, o) => s + o.blocks_found, 0)} paid_members=${nPaid}/${MEMBERS} with_shares=${Object.keys(firstShareMs).length} daa=${stats0.network.daa_score} target=${opens[0].target64}`); samples.push({ t: Math.round((Date.now() - t0) / 1000), node_daa: daas, heights, tips: tips.size, accepted: opens.map(o => o.accepted), stale: opens.map(o => o.stale), reorgs: opens.map(o => o.reorgs), rejected: opens.map(o => o.rejected), blocks: opens.map(o => o.blocks_found), paid_members: nPaid, daa: stats0.network.daa_score, net_hashrate: stats0.network.hashrate });
log(`t=${Math.round((Date.now() - t0) / 1000)}s nodes_daa=${daas.join('/')} heights=${Math.min(...heights)}..${Math.max(...heights)} tips=${tips.size} stale=${opens.reduce((s, o) => s + o.stale, 0)} reorgs=${opens.reduce((s, o) => s + o.reorgs, 0)} rejected=${opens.reduce((s, o) => s + o.rejected, 0)} blocks=${opens.reduce((s, o) => s + o.blocks_found, 0)} paid_members=${nPaid}/${MEMBERS} with_shares=${Object.keys(firstShareMs).length} daa=${stats0.network.daa_score} target=${opens[0].target64}`);
} catch (e) { log(`sample failed: ${e.message}`); } } catch (e) { log(`sample failed: ${e.message}`); }
} }
@ -212,6 +227,8 @@ summary.steps.verdict = {
first_payout_after_first_share_s: { n: latencies.length, p50: pct(0.5), p90: pct(0.9), max: latencies.length ? +latencies[latencies.length - 1].toFixed(1) : null, under_120_s: latencies.filter(x => x <= 120).length }, first_payout_after_first_share_s: { n: latencies.length, p50: pct(0.5), p90: pct(0.9), max: latencies.length ? +latencies[latencies.length - 1].toFixed(1) : null, under_120_s: latencies.filter(x => x <= 120).length },
chains: opens.map((o, d) => ({ daemon: d, height: o.height, tip: o.tip, accepted: o.accepted, stale: o.stale, reorgs: o.reorgs, rejected: o.rejected, blocks: o.blocks_found, last_reject: o.last_reject })), chains: opens.map((o, d) => ({ daemon: d, height: o.height, tip: o.tip, accepted: o.accepted, stale: o.stale, reorgs: o.reorgs, rejected: o.rejected, blocks: o.blocks_found, last_reject: o.last_reject })),
tips_agree: new Set(opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)).map(o => o.tip)).size === 1, tips_agree: new Set(opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)).map(o => o.tip)).size === 1,
nodes_daa: await Promise.all(Array.from({ length: NODES }, (_, n) => daaOf(n))),
nodes_peers: await Promise.all(Array.from({ length: NODES }, (_, n) => peersOf(n))),
// the honest daemons' fork rate (the withholder's view diverges by design: its own shares are in its chain alone) // the honest daemons' fork rate (the withholder's view diverges by design: its own shares are in its chain alone)
stale_rate: (() => { const h = opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)); const s = h.reduce((x, o) => x + o.stale, 0), a = h.reduce((x, o) => x + o.accepted, 0); return a ? +(s / a).toFixed(4) : null; })(), stale_rate: (() => { const h = opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)); const s = h.reduce((x, o) => x + o.stale, 0), a = h.reduce((x, o) => x + o.accepted, 0); return a ? +(s / a).toFixed(4) : null; })(),
withholder_stale_rate: WITHHOLD ? +(opens[DAEMONS - 1].stale / Math.max(1, opens[DAEMONS - 1].accepted)).toFixed(4) : null, withholder_stale_rate: WITHHOLD ? +(opens[DAEMONS - 1].stale / Math.max(1, opens[DAEMONS - 1].accepted)).toFixed(4) : null,