From 5f0efda0075ffc8557b0ec02f25bffb47316d71b Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:16:30 +0000 Subject: [PATCH] Project file: commit email is the GitHub-matched address everywhere Co-Authored-By: Claude Fable 5.1 --- proto-vdf/src/grind.rs | 138 +++++++++++++++++++++++++++++++++++++++++ proto-vdf/src/seed.rs | 113 +++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 proto-vdf/src/grind.rs create mode 100644 proto-vdf/src/seed.rs diff --git a/proto-vdf/src/grind.rs b/proto-vdf/src/grind.rs new file mode 100644 index 000000000..380732a26 --- /dev/null +++ b/proto-vdf/src/grind.rs @@ -0,0 +1,138 @@ +//! Seed-grinding model. +//! +//! One epoch is one hourly program. The program is drawn from a seed that depends on a +//! certified checkpoint, and the miner who produces the last block before the checkpoint +//! decides whether that block is published. Each block the miner produces at that point is a +//! candidate seed. +//! +//! Model (from the review's measurement): a miner's hash-rate advantage on a given program is +//! uniform on [0, ADV_MAX]. Honest miners get a random draw. A grinder with no delay compiles +//! and benchmarks the candidate's program inside the 2 second decision window and withholds +//! the block unless the advantage is in the top quartile, paying one block reward per +//! withheld block. With probability (1 - s) somebody else's block becomes the seed and the +//! grinder is stuck with a random draw. +//! +//! Revenue in an epoch with advantage a, for a miner with share s of the network: +//! blocks * s(1+a) / (s(1+a) + (1-s)) = blocks * s(1+a) / (1 + s a). +//! +//! With the delay the grinder cannot learn the program inside the window (the VDF takes +//! 10 minutes on a reference core), so it must publish and its gain is zero. Withholding +//! blindly has the same expected program and only costs the block. + +pub const BLOCKS_PER_EPOCH: f64 = 3600.0; +pub const ADV_MAX: f64 = 0.15; +pub const TOP_QUARTILE: f64 = 0.75 * ADV_MAX; + +pub struct Row { + pub share: f64, + pub honest: f64, + pub gain_no_delay: f64, + pub withheld_no_delay: f64, + pub gain_with_delay: f64, + pub mc_gain_no_delay: f64, + pub mc_withheld: f64, + pub p_grind_success: f64, +} + +fn revenue(share: f64, a: f64) -> f64 { + BLOCKS_PER_EPOCH * share * (1.0 + a) / (1.0 + share * a) +} + +/// Expected revenue for a uniform draw on [lo, hi], by midpoint integration. +fn expected_revenue(share: f64, lo: f64, hi: f64) -> f64 { + let n = 20_000; + let w = (hi - lo) / n as f64; + (0..n).map(|i| revenue(share, lo + (i as f64 + 0.5) * w)).sum::() / n as f64 +} + +pub fn analytic(share: f64) -> Row { + let p_keep = 0.25 * share; // own block and top quartile + let p_withhold = 0.75 * share; + let p_other = 1.0 - share; + let p_top = p_keep / (1.0 - p_withhold); + let p_random = p_other / (1.0 - p_withhold); + let e_withheld = p_withhold / (1.0 - p_withhold); + let honest = expected_revenue(share, 0.0, ADV_MAX); + let top = expected_revenue(share, TOP_QUARTILE, ADV_MAX); + let gain = p_top * top + p_random * honest - honest - e_withheld; + Row { + share, + honest, + gain_no_delay: gain, + withheld_no_delay: e_withheld, + gain_with_delay: 0.0, + mc_gain_no_delay: 0.0, + mc_withheld: 0.0, + p_grind_success: p_top, + } +} + +struct Xoshiro(u64, u64, u64, u64); +impl Xoshiro { + fn new(seed: u64) -> Self { + let mut s = seed; + let mut next = || { + s = s.wrapping_add(0x9E3779B97F4A7C15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + }; + Xoshiro(next(), next(), next(), next()) + } + fn next_u64(&mut self) -> u64 { + let result = self.1.wrapping_mul(5).rotate_left(7).wrapping_mul(9); + let t = self.1 << 17; + self.2 ^= self.0; + self.3 ^= self.1; + self.1 ^= self.2; + self.0 ^= self.3; + self.2 ^= t; + self.3 = self.3.rotate_left(45); + result + } + fn uniform(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } +} + +/// Monte Carlo over `epochs` epochs. Returns (mean gain in blocks, mean withheld blocks). +pub fn monte_carlo(share: f64, epochs: u64, seed: u64) -> (f64, f64) { + let mut rng = Xoshiro::new(seed ^ (share * 1e6) as u64); + let honest = expected_revenue(share, 0.0, ADV_MAX); + let mut total_gain = 0.0; + let mut total_withheld = 0u64; + for _ in 0..epochs { + let mut withheld = 0u64; + let a; + loop { + let mine = rng.uniform() < share; + let draw = rng.uniform() * ADV_MAX; + if !mine { + a = draw; + break; + } + if draw >= TOP_QUARTILE { + a = draw; + break; + } + withheld += 1; + } + total_gain += revenue(share, a) - honest - withheld as f64; + total_withheld += withheld; + } + (total_gain / epochs as f64, total_withheld as f64 / epochs as f64) +} + +pub fn table(epochs: u64) -> Vec { + [0.1, 0.2, 0.3, 0.4] + .iter() + .map(|&s| { + let mut row = analytic(s); + let (g, w) = monte_carlo(s, epochs, 0x1A9E); + row.mc_gain_no_delay = g; + row.mc_withheld = w; + row + }) + .collect() +} diff --git a/proto-vdf/src/seed.rs b/proto-vdf/src/seed.rs new file mode 100644 index 000000000..c5d6f96a9 --- /dev/null +++ b/proto-vdf/src/seed.rs @@ -0,0 +1,113 @@ +//! Seed pipeline: certified checkpoint hash -> VDF -> program seed. +//! +//! program_seed = SHA256("igneum-program-seed" || checkpoint_hash || T || serialize(y)) +//! where y = x^(2^T) in the class group whose discriminant is derived from the checkpoint +//! hash, and x is the group's generator (2, 1, (1-D)/8). Because D is fresh per checkpoint +//! nobody can precompute anything before the checkpoint is certified, and because the group +//! order is unknown nobody can shortcut the T squarings. +//! +//! The RSA stand-in path exists for timing comparison only. + +use crate::classgroup::{ClassGroup, Form}; +use crate::group::Group; +use crate::hash::sha256; +use crate::rsa::RsaGroup; +use crate::wesolowski::{self, Proof}; + +pub const DISCRIMINANT_BITS: u32 = 1024; + +#[derive(Clone, Debug)] +pub struct EpochProof { + pub t: u64, + pub y: Vec, + pub pi: Vec, +} + +impl EpochProof { + pub fn wire_size(&self) -> usize { + 8 + self.y.len() + self.pi.len() + } +} + +pub fn class_group_for(checkpoint_hash: &[u8; 32]) -> ClassGroup { + let mut seed = Vec::with_capacity(64); + seed.extend_from_slice(b"igneum-epoch-discriminant"); + seed.extend_from_slice(checkpoint_hash); + ClassGroup::from_seed(&seed, DISCRIMINANT_BITS) +} + +fn derive_seed(checkpoint_hash: &[u8; 32], t: u64, y_bytes: &[u8]) -> [u8; 32] { + sha256(&[b"igneum-program-seed", checkpoint_hash, &t.to_be_bytes(), y_bytes]) +} + +/// Run the delay and produce (program_seed, proof). Anyone can do this; it takes T squarings. +pub fn epoch_seed(checkpoint_hash: &[u8; 32], t: u64, threads: usize) -> ([u8; 32], EpochProof) { + let g = class_group_for(checkpoint_hash); + let x: Form = g.generator(); + let spacing = wesolowski::choose_spacing(t, 1 << 16); + let kappa = pick_kappa(spacing); + let spacing = spacing - spacing % kappa as u64; + let spacing = spacing.max(kappa as u64); + let ev = wesolowski::eval(&g, &x, t, spacing); + let proof = wesolowski::prove(&g, &x, &ev, kappa, threads); + let y_bytes = g.serialize(&proof.y); + let seed = derive_seed(checkpoint_hash, t, &y_bytes); + (seed, EpochProof { t, y: y_bytes, pi: g.serialize(&proof.pi) }) +} + +/// Verify a (checkpoint, seed, proof) triple in milliseconds without running the delay. +pub fn verify_epoch_seed(checkpoint_hash: &[u8; 32], program_seed: &[u8; 32], proof: &EpochProof) -> bool { + let g = class_group_for(checkpoint_hash); + let x = g.generator(); + let y = match g.deserialize(&proof.y) { + Some(v) => v, + None => return false, + }; + let pi = match g.deserialize(&proof.pi) { + Some(v) => v, + None => return false, + }; + if !wesolowski::verify(&g, &x, &Proof { y, pi }, proof.t) { + return false; + } + derive_seed(checkpoint_hash, proof.t, &proof.y) == *program_seed +} + +/// Same pipeline over the RSA stand-in, for timing comparison only. +pub fn epoch_seed_rsa(g: &RsaGroup, checkpoint_hash: &[u8; 32], t: u64, threads: usize) -> ([u8; 32], EpochProof) { + let x = g.hash_to_elem(checkpoint_hash); + let spacing = wesolowski::choose_spacing(t, 1 << 16); + let kappa = pick_kappa(spacing); + let spacing = (spacing - spacing % kappa as u64).max(kappa as u64); + let ev = wesolowski::eval(g, &x, t, spacing); + let proof = wesolowski::prove(g, &x, &ev, kappa, threads); + let y_bytes = g.serialize(&proof.y); + let seed = derive_seed(checkpoint_hash, t, &y_bytes); + (seed, EpochProof { t, y: y_bytes, pi: g.serialize(&proof.pi) }) +} + +pub fn verify_epoch_seed_rsa(g: &RsaGroup, checkpoint_hash: &[u8; 32], program_seed: &[u8; 32], proof: &EpochProof) -> bool { + let x = g.hash_to_elem(checkpoint_hash); + let y = match g.deserialize(&proof.y) { + Some(v) => v, + None => return false, + }; + let pi = match g.deserialize(&proof.pi) { + Some(v) => v, + None => return false, + }; + if !wesolowski::verify(g, &x, &Proof { y, pi }, proof.t) { + return false; + } + derive_seed(checkpoint_hash, proof.t, &proof.y) == *program_seed +} + +/// Digit width: 12 bits when the spacing allows it, smaller for tiny T. +pub fn pick_kappa(spacing: u64) -> u32 { + for k in [12u32, 10, 8, 6, 4, 2, 1] { + if spacing >= (k as u64) * 4 || k == 1 { + return k; + } + } + 1 +}