Open pool: the share chain's first target as a chain constant, the retarget on the window's mean target with a ceiling, withheld shares kept out of sync replies, the share line carries the cache rule for verify-share, the gate harness cleans up after itself and reads the first payout to 5 s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 11:42:34 +00:00
parent 3e5271ba6a
commit f7694c66d2
8 changed files with 150 additions and 40 deletions

View file

@ -382,8 +382,13 @@ gossips shares with the other members' daemons. A share is a template at the cha
block target; the executor pays a blue block's producer share by its split from `pool_split_activation_daa` on
(`evm::split_producer`: integer parts by weight, the dust to the finder), so every node computes the credits from the
block alone, as it does the 20 percent. The chain: one parent per share, heaviest work wins, a fork's loser is stale and
pays nothing, a 10-second target by default held by a 30-share retarget, a PPLNS window of 2,160 shares including the
share itself, the dev fee as one split entry.
pays nothing, a 10-second target by default held by a 30-share retarget (the DAA's shape: the window's mean target times
actual over expected span, clamped x4 per share and ten doublings above the first target; the first version compounded
the last target by the window's ratio at every share and ran to the saturation cap in thirty shares, every hash a share:
the first smoke run), a first target that is a chain constant (the network's genesis block target eight times easier,
never a node's current template, which differs between members), a PPLNS window of 2,160 shares including the share
itself, the dev fee as one split entry (the weights scaled by 2^32 before the fee's division, or a genesis share's few
dozen hashes of work rounded the dev entry to a third of its share: the unit test).
| File | What |
|---|---|

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.
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 a retarget over the last 30 shares' header timestamps, clamped to a factor of 4 per share, never above 2^62; the first target is the block target eight times easier. 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 those of the member's own node for the share's DAA score, 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.
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.
@ -173,7 +173,7 @@ Added 7 October 2026 (mission item 11; docs/plans/pool.md section 10). Lineage:
|---|---|---|
| Transport | TLS 1.3, newline-delimited JSON | Implemented (7 October 2026); plain TCP allowed on the devnet only |
| Binding | `igneum-pool-binding-v1/` \|\| chain id LE \|\| 32-byte exporter (`EXPORTER-igneum-pool-binding`, context chain id LE), tag `IGNEUM_POOL_BINDING_V1_...` | Implemented |
| Open pool: `chain_share_s`, `window_shares`, `open_dev_fee_percent`, split entries | 10 s, 2,160, 1 percent, at most 256 | Implemented (9.12); the public chain's rate is open, 9.12 item 8 |
| Open pool: `chain_share_s`, `window_shares`, `open_dev_fee_percent`, split entries, first target | 10 s, 2,160, 1 percent, at most 256, `target64(genesis bits) << 3` | Implemented (9.12); the public chain's rate is open, 9.12 item 8 |
| Open pool: `pool_split_activation_daa` | never by default; 0 on a new chain; in the digest once set | Implemented (params, executor) |
| Ports | 4461 / 4462 / 4463 | Designed |
| Required modes | A (full template) and C (declared); B optional | Designed |

View file

@ -60,7 +60,7 @@ igneum-pool --node grpc://127.0.0.1:26610 --evm-rpc http://127.0.0.1:26790 \
| `--alert-webhook` | none | Q72: an `http://` URL POSTed once per address that sent shares before and none for ten minutes |
| `--open` | off | the open pool: the share sidechain with no operator (below) |
| `--p2p-listen`, `--peer` | `0.0.0.0:<member port + 10>`, none | the open pool's share gossip: where to listen, who to connect to |
| `--chain-share-s`, `--window-shares`, `--open-dev-fee-percent`, `--open-chain` | `10`, `2160`, `1`, `igneum-open-v1` | the share chain's constants; every member of one chain holds the same or its shares are refused |
| `--chain-share-s`, `--window-shares`, `--open-dev-fee-percent`, `--open-chain`, `--chain-genesis-target64` | `10`, `2160`, `1`, `igneum-open-v1`, the network's genesis block target eight times easier | the share chain's constants; every member of one chain holds the same or its shares are refused |
At start the pool prints its payout address. That address is the pool's coinbase address: every template names it in
the `IGNA` field, so the execution layer credits it 80% of every blue block the pool finds. The 20% proving share goes

View file

@ -58,6 +58,9 @@ pub struct Config {
pub window_shares: usize,
pub open_dev_fee_percent: u32,
pub open_chain: String,
/// Open pool: the share chain's first target (16 hex), a chain constant every member shares; the default is the
/// network's genesis block target eight times easier (`open.rs`)
pub chain_genesis_target64: Option<u64>,
/// Open pool test flag: keep this daemon's own shares to itself (the withheld-share case of the gate)
pub withhold_shares: bool,
}
@ -72,7 +75,7 @@ impl Config {
\x20 [--name \"Igneum pool\"] [--public-url host:4463] [--verify-threads 2] [--snapshot-interval-s 15]\n\
\x20 [--tls-cert <pem> --tls-key <pem> | --tls-self-signed] [--allow-plain] [--alert-webhook <url>]\n\
\x20 [--open [--p2p-listen 0.0.0.0:4473] [--peer host:port]... [--chain-share-s 10] [--window-shares 2160]\n\
\x20 [--open-dev-fee-percent 1] [--open-chain igneum-open-v1]]"
\x20 [--open-dev-fee-percent 1] [--open-chain igneum-open-v1] [--chain-genesis-target64 <hex>]]"
);
std::process::exit(2)
}
@ -114,6 +117,7 @@ impl Config {
window_shares: 2160,
open_dev_fee_percent: 1,
open_chain: "igneum-open-v1".into(),
chain_genesis_target64: None,
withhold_shares: false,
};
let mut i = 0;
@ -159,6 +163,7 @@ impl Config {
"--window-shares" => c.window_shares = val().parse::<usize>().ok().filter(|v| *v > 0).unwrap_or_else(|| Config::usage()),
"--open-dev-fee-percent" => c.open_dev_fee_percent = val().parse::<u32>().ok().filter(|v| *v <= 50).unwrap_or_else(|| Config::usage()),
"--open-chain" => c.open_chain = val(),
"--chain-genesis-target64" => c.chain_genesis_target64 = Some(u64::from_str_radix(val().trim_start_matches("0x"), 16).unwrap_or_else(|_| Config::usage())),
"--withhold-shares" => c.withhold_shares = true,
"-h" | "--help" => Config::usage(),
_ => Config::usage(),

View file

@ -31,8 +31,21 @@ use std::sync::{Arc, Mutex};
pub const DEV_FEE_ADDRESS: &str = "0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126";
pub const DEV_FEE_ADDRESS_DEVNET: &str = "0xdfaea67368f3e3753397d878f97efe6aa8020c2e";
/// The share chain's first target against the block target: eight times easier, then the retarget takes over.
/// The share chain's first target against the network's genesis block target: eight times easier, then the retarget
/// takes over. A chain constant (every member must hold the same, so it never comes from a node's current template).
pub const GENESIS_TARGET_SHIFT: u32 = 3;
/// The devnet's genesis bits (infra/fast-time/README.md: "the devnet value 0x1d100000 is the GPU difficulty"); the
/// testnet and mainnet values are set at their genesis, until then the same.
pub const DEVNET_GENESIS_BITS: u32 = 0x1d10_0000;
/// The first chain target for a network: `target64(genesis bits) << 3`, unless the chain names its own.
pub fn genesis_target_for(cfg: &crate::config::Config) -> u64 {
if let Some(t) = cfg.chain_genesis_target64 {
return t.max(1).min(crate::sidechain::MAX_TARGET);
}
let t = target64(DEVNET_GENESIS_BITS);
t.checked_shl(GENESIS_TARGET_SHIFT).filter(|x| (*x >> GENESIS_TARGET_SHIFT) == t).unwrap_or(u64::MAX >> 1).min(crate::sidechain::MAX_TARGET)
}
pub struct Open {
pub chain: Mutex<ShareChain>,
@ -51,6 +64,8 @@ pub struct Open {
log: Mutex<Option<std::fs::File>>,
pub shares_log_path: PathBuf,
withhold: bool,
/// Our own shares' hashes while withholding (never sent, not even on a peer's sync request)
own: Mutex<std::collections::HashSet<[u8; 32]>>,
pub local_shares: AtomicU64,
pub peer_shares: AtomicU64,
pub rejected: AtomicU64,
@ -66,8 +81,10 @@ impl Open {
let shares_log_path = state_path.with_file_name("shares.log");
let log = std::fs::OpenOptions::new().create(true).append(true).open(&shares_log_path).ok();
let (gossip, _) = tokio::sync::broadcast::channel(8192);
let mut chain = ShareChain::new(params);
chain.genesis_target = Some(genesis_target_for(cfg));
Self {
chain: Mutex::new(ShareChain::new(params)),
chain: Mutex::new(chain),
chain_name: cfg.open_chain.clone(),
dev_fee_percent: cfg.open_dev_fee_percent,
engine,
@ -78,6 +95,7 @@ impl Open {
log: Mutex::new(log),
shares_log_path,
withhold: cfg.withhold_shares,
own: Mutex::new(std::collections::HashSet::new()),
local_shares: AtomicU64::new(0),
peer_shares: AtomicU64::new(0),
rejected: AtomicU64::new(0),
@ -103,12 +121,8 @@ impl Open {
/// The share chain's parent, the chain target and the full extra data for a member's template now.
pub fn stamp_for(&self, member: &Member, _info: &RpcPowEpochInfo, raw: &RpcRawBlock) -> Result<([u8; 32], u64, Vec<u8>), String> {
let mut chain = self.chain.lock().unwrap();
if chain.genesis_target.is_none() {
let t = target64(raw.header.bits);
chain.genesis_target = Some(t.checked_shl(GENESIS_TARGET_SHIFT).filter(|x| (*x >> GENESIS_TARGET_SHIFT) == t).unwrap_or(u64::MAX >> 1).min(crate::sidechain::MAX_TARGET));
println!("{} OPEN chain '{}': first target {:016x} from the block target {:016x}", unix_ms(), self.chain_name, chain.genesis_target.unwrap(), t);
}
let chain = self.chain.lock().unwrap();
let _ = raw;
let parent = chain.tip.unwrap_or(GENESIS_PARENT);
let target = chain.target_after(&parent).ok_or("no chain target")?;
let extra = chain.extra_data_for(&member.reveal, Self::member_address(member), &parent, target);
@ -221,7 +235,9 @@ impl Open {
if origin != 0 {
self.peer_shares.fetch_add(1, Ordering::Relaxed);
}
if !(origin == 0 && self.withhold) {
if origin == 0 && self.withhold {
self.own.lock().unwrap().insert(hash);
} else {
let _ = self.gossip.send((share.clone(), origin));
}
// orphans that waited for this share
@ -287,6 +303,17 @@ impl Open {
})
}
/// The shares a peer's sync gets: the tip's ancestry from `from`, without our own while withholding.
pub fn sync_range(&self, from: u64, count: usize) -> Vec<Arc<Share>> {
let c = self.chain.lock().unwrap();
let v = c.range(from, count);
if !self.withhold {
return v;
}
let own = self.own.lock().unwrap();
v.into_iter().filter(|s| !own.contains(&s.hash32())).collect()
}
pub fn shares_json(&self, from: u64, count: usize) -> serde_json::Value {
let c = self.chain.lock().unwrap();
serde_json::json!({ "ok": true, "shares": c.range(from, count.min(2048)).iter().map(|s| serde_json::json!({"hash": s.hash, "parent": s.parent, "height": s.height, "address": s.address, "timestamp": s.timestamp, "daa_score": s.daa_score, "share_target64": s.share_target64, "block": s.is_block()})).collect::<Vec<_>>() })

View file

@ -142,7 +142,7 @@ async fn session(pool: Arc<Pool>, open: Arc<Open>, sock: TcpStream, remote: Stri
}
"get_shares" => {
let from = v["from"].as_u64().unwrap_or(0);
let shares = { open.chain.lock().unwrap().range(from, 4096) };
let shares = open.sync_range(from, 4096);
println!("{} p2p {remote}: sending {} shares from height {from}", unix_ms(), shares.len());
for s in shares {
send(share_msg(&s));

View file

@ -31,13 +31,17 @@ pub const RETARGET_SHARES: usize = 30;
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 thousandfold is restarted with a new first target, not followed into a flood).
pub const MAX_EASING_DOUBLINGS: u32 = 10;
/// 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).
pub const MAX_REORG_DEPTH: u64 = 256;
pub const GENESIS_PARENT: [u8; 32] = [0u8; 32];
/// The seeds of a share's epoch, on the wire and in the log.
/// 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,
@ -45,11 +49,48 @@ pub struct SeedsWire {
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 {
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 }
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}"))?;
@ -185,13 +226,17 @@ impl ShareChain {
out
}
/// The chain target for a share extending `parent` (`None` or the zero hash: a genesis share). Retarget over the
/// last `RETARGET_SHARES` shares' header timestamps, clamped to a factor of `RETARGET_CLAMP` per share, never
/// above `MAX_TARGET`, never below 1. Integer arithmetic only.
/// 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?;
let ceiling = genesis.checked_shl(MAX_EASING_DOUBLINGS).unwrap_or(u64::MAX).min(MAX_TARGET) as u128;
if *parent == GENESIS_PARENT {
return Some(genesis.min(MAX_TARGET));
return Some((genesis as u128).min(ceiling) as u64);
}
let chain = self.ancestry(parent, RETARGET_SHARES + 1);
let last = chain.first()?;
@ -201,12 +246,13 @@ impl ShareChain {
}
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;
// target x actual / expected: a slow chain gets an easier (larger) target
let mut next = prev_target * actual_ms / expected_ms;
// mean target x actual / expected: a slow window gets an easier (larger) target
let mut next = mean_target * actual_ms / expected_ms;
next = next.clamp(prev_target / RETARGET_CLAMP, prev_target.saturating_mul(RETARGET_CLAMP));
Some((next.max(1)).min(MAX_TARGET as u128) as u64)
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`
@ -417,9 +463,10 @@ pub fn verify_share_cli(args: &[String]) -> i32 {
return 2;
}
};
s.seeds.install();
let engine = IgneumEngine::new();
match check_pow(&engine, &s) {
Ok(()) => println!("SHARE OK {} height {} by {} lane {} <= target {} (work {} hashes)", &s.hash[..16], s.height, s.address, s.lane_hash, s.share_target64, s.work()),
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;

View file

@ -42,6 +42,9 @@ const POOL = opt('--pool', `${ROOT}/pool/target/release/igneum-pool`);
const GENESIS_BITS = Number(opt('--genesis-bits', '0x1e400000'));
const DATASET_LOG2 = +opt('--dataset-log2', 24);
const BASE = 30500, SUFFIX = 3050, DAEMON_BASE = 30600, P2P_BASE = 30700, HTTP_BASE = 30800;
// the share chain's first target: the network's genesis block target (Kaspa compact bits to a 64-bit target) eight times easier
const target64 = (bits) => { const exp = BigInt(bits >>> 24), mant = BigInt(bits & 0xffffff); const t256 = exp <= 3n ? mant >> (8n * (3n - exp)) : mant << (8n * (exp - 3n)); return t256 >> 192n; };
const CHAIN_GENESIS_TARGET = (target64(GENESIS_BITS) << 3n).toString(16).padStart(16, '0');
const FAST = `${ROOT}/infra/fast-time/override-60x.json`;
const log = (...a) => { const l = new Date().toISOString().slice(11, 23) + ' ' + a.join(' '); console.log(l); try { appendFileSync(join(SCRATCH, 'gate.log'), l + '\n'); } catch {} };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
@ -51,6 +54,9 @@ const started = [];
const stopAll = () => { for (const p of started.splice(0).reverse()) { try { p.kill('SIGINT'); } catch {} } };
process.on('SIGINT', () => { stopAll(); process.exit(130); });
process.on('SIGTERM', () => { stopAll(); process.exit(143); });
// a harness fault never leaves a network behind (the first smoke run did, and the next run read the old daemons)
process.on('uncaughtException', (e) => { console.error(e); stopAll(); process.exit(1); });
process.on('unhandledRejection', (e) => { console.error(e); stopAll(); process.exit(1); });
const summary = { started: new Date().toISOString(), members: MEMBERS, daemons: DAEMONS, nodes: NODES, secs: SECS, chain_share_s: +CHAIN_SHARE_S, window: WINDOW, threads: THREADS, genesis_bits: '0x' + GENESIS_BITS.toString(16), dataset_log2: DATASET_LOG2, withhold: WITHHOLD, steps: {} };
const save = () => writeFileSync(join(SCRATCH, 'summary.json'), JSON.stringify(summary, null, 2));
@ -72,6 +78,7 @@ function run(name, bin, a, env = {}, nice = 0) {
const p = nice ? spawn('nice', ['-n', String(nice), bin, ...a], { stdio: ['ignore', out, out], env: { ...process.env, ...env } }) : spawn(bin, a, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
p.on('exit', (code) => { p.exitCode2 = code; });
started.push(p);
appendFileSync(join(SCRATCH, 'pids'), `${p.pid} ${name}\n`);
return p;
}
const logOf = (name) => { try { return readFileSync(join(SCRATCH, `${name}.log`), 'utf8'); } catch { return ''; } };
@ -82,6 +89,8 @@ async function evm(method, params = [], n = 0) {
const j = await r.json(); if (j.error) throw new Error(j.error.message || JSON.stringify(j.error)); return j.result;
}
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)
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
const nodePorts = (n) => ({ grpc: BASE + n * 4, p2p: BASE + n * 4 + 1, json: BASE + n * 4 + 2, evm: BASE + n * 4 + 3 });
@ -104,12 +113,13 @@ for (let d = 0; d < DAEMONS; d++) {
peers.delete(d);
const a = ['--open', '--node', `grpc://127.0.0.1:${n.grpc}`, '--evm-rpc', `http://127.0.0.1:${n.evm}`, '--listen', `127.0.0.1:${DAEMON_BASE + d}`, '--http', `127.0.0.1:${HTTP_BASE + d}`,
'--p2p-listen', `127.0.0.1:${P2P_BASE + d}`, '--data-dir', join(SCRATCH, `daemon${d}`), '--network', 'devnet', '--chain-share-s', CHAIN_SHARE_S, '--window-shares', String(WINDOW),
'--name', `open daemon ${d}`, '--public-url', `127.0.0.1:${DAEMON_BASE + d}`, '--share-interval-s', '10', '--verify-threads', '2', '--snapshot-interval-s', '15'];
'--name', `open daemon ${d}`, '--public-url', `127.0.0.1:${DAEMON_BASE + d}`, '--share-interval-s', '10', '--verify-threads', '2', '--snapshot-interval-s', '15', '--chain-genesis-target64', CHAIN_GENESIS_TARGET];
for (const p of peers) a.push('--peer', `127.0.0.1:${P2P_BASE + p}`);
if (WITHHOLD && d === DAEMONS - 1) a.push('--withhold-shares');
run(`daemon${d}`, POOL, a, FT);
}
for (let d = 0; d < DAEMONS; d++) for (let i = 0; i < 60; i++) { await sleep(500); try { const h = await fetch(`http://127.0.0.1:${HTTP_BASE + d}/api/stats`); if (h.ok) break; } catch {} }
for (let d = 0; d < DAEMONS; d++) if (/POOL NOT STARTED/.test(logOf(`daemon${d}`))) { log(`daemon ${d} did not start: ${logOf(`daemon${d}`).trim().split('\n').pop()}`); stopAll(); process.exit(2); }
log(`${DAEMONS} daemons up${WITHHOLD ? ` (daemon ${DAEMONS - 1} withholds its shares)` : ''}`);
// 3. the members: one key and one payout address each, spread over the daemons
@ -126,7 +136,22 @@ const t0 = Date.now();
const firstShareMs = {}, firstPaidMs = {};
const samples = [];
const balance0 = {};
for (const a of ADDR) balance0[a] = BigInt(await evm('eth_getBalance', [a, 'latest']));
for (const a of ADDR) balance0[a] = (await bal(a));
// the first-payout watcher: every 5 s, the first share of every address (header time, from the daemons' chains) and
// the first chain credit (eth_getBalance), so the latency is read to 5 s
let watching = true;
const watcher = (async () => {
while (watching) {
await sleep(5000);
try {
for (let d = 0; d < DAEMONS; d++) {
const sh = await api(d, `/api/open/shares?from=0&limit=2048`);
for (const s of sh.shares) if (firstShareMs[s.address] == null || s.timestamp < firstShareMs[s.address]) firstShareMs[s.address] = s.timestamp;
}
for (const a of ADDR) if (firstPaidMs[a] == null) { const b = await bal(a); if (b > balance0[a]) firstPaidMs[a] = Date.now(); }
} catch (e) { log(`watcher: ${e.message}`); }
}
})();
// 4. the run: every 30 s, every daemon's chain and the chain-side balances
while (Date.now() - t0 < SECS * 1000) {
@ -136,23 +161,17 @@ while (Date.now() - t0 < SECS * 1000) {
const stats0 = await api(0, '/api/stats');
const heights = opens.map(o => o.height);
const tips = new Set(opens.map(o => o.tip));
// first shares per member, from the daemons' share lists (their addresses)
for (let d = 0; d < DAEMONS; d++) {
const sh = await api(d, `/api/open/shares?from=0&limit=2048`);
for (const s of sh.shares) if (firstShareMs[s.address] == null) firstShareMs[s.address] = s.timestamp;
}
const paid = [];
for (const a of ADDR) { const b = BigInt(await evm('eth_getBalance', [a, 'latest'])); if (b > balance0[a] && firstPaidMs[a] == null) firstPaidMs[a] = Date.now(); paid.push(b - balance0[a]); }
const nPaid = paid.filter(x => x > 0n).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 });
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}`);
} catch (e) { log(`sample failed: ${e.message}`); }
}
// 5. the verdicts
watching = false; await watcher;
const opens = await Promise.all(Array.from({ length: DAEMONS }, (_, d) => api(d, '/api/open').then(j => j.open)));
const credits = {};
for (const a of ADDR) credits[a] = BigInt(await evm('eth_getBalance', [a, 'latest'])) - balance0[a];
for (const a of ADDR) credits[a] = (await bal(a)) - balance0[a];
const withheld = WITHHOLD ? ADDR.filter((_, m) => memberDaemon(m) === DAEMONS - 1) : [];
const honest = ADDR.filter(a => !withheld.includes(a));
const paidHonest = honest.filter(a => credits[a] > 0n);
@ -162,6 +181,9 @@ const withShares = honest.filter(a => firstShareMs[a] != null);
const withheldSeen = [];
for (let d = 0; d < DAEMONS - (WITHHOLD ? 1 : 0); d++) { const sh = await api(d, '/api/open/shares?from=0&limit=2048'); for (const s of sh.shares) if (withheld.includes(s.address)) withheldSeen.push(s.hash); }
const withheldBlocks = WITHHOLD ? opens[DAEMONS - 1].blocks_found : 0;
// an honest daemon's block never pays a withheld address (its split is the chain the withholder kept its shares out of)
let honestBlocks = 0, withheldPaidByHonest = 0;
for (let d = 0; d < DAEMONS - (WITHHOLD ? 1 : 0); d++) { const bl = await api(d, '/api/blocks?limit=1000'); for (const b of bl.blocks) { honestBlocks++; if (b.payees.some(p => withheld.includes(p.address))) withheldPaidByHonest++; } }
// first payout latency: from the member's first share (header time) to the first chain credit
const latencies = honest.filter(a => firstShareMs[a] && firstPaidMs[a]).map(a => (firstPaidMs[a] - firstShareMs[a]) / 1000);
latencies.sort((x, y) => x - y);
@ -173,7 +195,8 @@ try {
const lines = readFileSync(join(SCRATCH, 'daemon0', 'shares.log'), 'utf8').trim().split('\n').filter(Boolean);
const first = lines[0];
const share = JSON.parse(first.replace(/^SHARE /, ''));
const blocks = (await api(0, '/api/blocks?limit=1000')).blocks.filter(b => b.status === 'confirmed');
const all = (await api(0, '/api/blocks?limit=1000')).blocks.filter(b => b.status !== 'orphan');
const blocks = all.some(b => b.status === 'confirmed') ? all.filter(b => b.status === 'confirmed') : all;
const after = blocks.filter(b => b.ts_ms >= share.timestamp).sort((x, y) => x.ts_ms - y.ts_ms)[0];
let blockRaw = null;
if (after) { const s = await api(0, `/api/open/share/${after.hash}`); if (s.ok) blockRaw = JSON.stringify(s.share.raw); }
@ -185,14 +208,17 @@ summary.steps.run = { samples, source: 'GET /api/open and /api/open/shares on ev
summary.steps.verdict = {
honest_members: honest.length, honest_with_a_share: withShares.length, honest_paid_by_coinbase: paidHonest.length,
withheld_members: withheld.length, withheld_shares_seen_elsewhere: withheldSeen.length, withheld_credits_ign: withheld.map(a => Number(credits[a]) / 1e18), withheld_daemon_blocks: withheldBlocks,
honest_blocks: honestBlocks, honest_blocks_paying_a_withheld_address: withheldPaidByHonest,
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 })),
tips_agree: new Set(opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)).map(o => o.tip)).size === 1,
stale_rate: (() => { const s = opens.reduce((x, o) => x + o.stale, 0), a = opens.reduce((x, o) => x + o.accepted, 0); return a ? +(s / a).toFixed(4) : null; })(),
// 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; })(),
withholder_stale_rate: WITHHOLD ? +(opens[DAEMONS - 1].stale / Math.max(1, opens[DAEMONS - 1].accepted)).toFixed(4) : null,
total_credited_ign: Number(ADDR.reduce((s, a) => s + credits[a], 0n)) / 1e18,
drop_proof: proof,
first_share_line: shareLine,
pass: paidHonest.length === honest.length && withheldSeen.length === 0 && latencies.length > 0 && pct(0.9) != null && pct(0.9) <= 120 && proof && proof.exit === 0,
pass: paidHonest.length === honest.length && withheldSeen.length === 0 && withheldPaidByHonest === 0 && latencies.length > 0 && pct(0.9) != null && pct(0.9) <= 120 && proof && proof.exit === 0,
source: 'eth_getBalance before and after on node 0 (the coinbase rule, pool_split_activation_daa 0); the daemons\' /api/open; daemon0/shares.log; igneum-pool verify-share',
};
save();