Project file: commit email is the GitHub-matched address everywhere

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-03 16:16:30 +00:00
parent 6b942f4fbd
commit 5f0efda007
2 changed files with 251 additions and 0 deletions

138
proto-vdf/src/grind.rs Normal file
View file

@ -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::<f64>() / 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<Row> {
[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()
}

113
proto-vdf/src/seed.rs Normal file
View file

@ -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<u8>,
pub pi: Vec<u8>,
}
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
}