diff --git a/docs/fork-map.md b/docs/fork-map.md new file mode 100644 index 00000000..80983890 --- /dev/null +++ b/docs/fork-map.md @@ -0,0 +1,31 @@ +# Igneum fork map for rusty-kaspa + +Base: rusty-kaspa commit `01b532e8b553523216471682649693af92f0fd16` (v2.1.0, 22 Sep 2026), cloned to `vendor/rusty-kaspa` on 3 Oct 2026. +Line numbers are from that commit. Re-check them after any vendor update. "Igneum change" follows the design paragraph in CLAUDE.md. +Risk: how much of the node's logic the change touches and how easy it is to get wrong. Nothing here has been implemented yet. + +## The six fork points + +| # | What | File (vendor/rusty-kaspa/) | Lines | Today in Kaspa | Igneum changes it to | Risk | +|---|---|---|---|---|---|---| +| a1 | PoW hash, stage 1: `PowHash` = cSHAKE256("ProofOfWorkHash") over pre-PoW header hash, timestamp, nonce | `crypto/hashes/src/pow_hashers.rs` | 4, 9 to 37 | Keccak f1600 with a fixed initial state | Replaced by the random-program GPU hash: the kernel is drawn per ~1 h epoch from a VDF seed of a certified checkpoint, reads a 256 MB RandomX-style cache (8 dependent reads per item), warp-unit CPU-verifiable | High. New primitive, needs bit-exact GPU and CPU paths (proto-metal and proto-cuda already agree on one program). | +| a2 | PoW hash, stage 2: `KHeavyHash` = 64x64 4-bit matrix multiply then cSHAKE256("HeavyHash") | `crypto/hashes/src/pow_hashers.rs`; `consensus/pow/src/matrix.rs` | 39 to 60; `Matrix::generate` 28 to 37, `heavy_hash` 101 to 125 | Matrix from xoshiro seeded by the pre-PoW hash, rank-64 check | Deleted. The program generator replaces the matrix; the epoch seed replaces the per-block matrix seed | Medium. Pure removal, but `kaspa_pow::State` callers assume a per-header precompute. | +| a3 | PoW state and check: `State::new`, `calculate_pow`, `check_pow`, `calc_level_from_pow` | `consensus/pow/src/lib.rs` | 19 to 56, 58 to 77 | `pow <= target` on a Uint256 from the heavy hash | Same interface, new body. `State` must carry the epoch program (looked up by header DAA score or timestamp), not a matrix | Medium. Block level (used by pruning proofs, 74 to 77) assumes a uniform 256-bit output; the new hash must keep that. | +| a4 | PoW validation call site in header processing | `consensus/src/pipeline/header_processor/pre_ghostdag_validation.rs` | 16 to 22 (`validate_header_in_isolation`), 102 to 106 (`check_pow_and_calc_block_level`) | `RuleError::InvalidPoW` unless `skip_proof_of_work` | Unchanged shape, but the check needs the epoch program for that header's epoch, so it gains a dependency on chain state (the VDF-certified checkpoint) | High. Header validation today needs nothing beyond the header. An epoch lookup during IBD and pruning-proof validation (`consensus/src/processes/pruning_proof/validate.rs:192`) must be deterministic from headers alone. | +| a5 | Pre-PoW header hash (what the nonce commits to) | `consensus/core/src/hashing/header.rs` | 7 to 30 (`hash_override_nonce_time`), 33 to 35 (`hash`) | BlockHash over version, parents, 3 roots, timestamp, bits, nonce, daa_score, blue_score, blue_work, pruning_point | Adds the vote key (see d) between `pruning_point` and the end, so the vote key is PoW-committed | Low. Mechanical, but every header hash test vector changes, genesis hashes included. | +| b1 | Block subsidy schedule | `consensus/src/processes/coinbase.rs` | 23 (`SECONDS_PER_MONTH`), 25 to 26 (table type), 222 to 234 (`calc_block_subsidy`), 236 to 255 (`subsidy_month`), 280 (`SUBSIDY_BY_MONTH_TABLE`, 426 entries) | Pre-deflationary flat subsidy, then a 426-month table (approximate reading: yearly halving in 12 monthly steps, see the table itself) | Hard cap 4 billion, halving every two years from genesis, no pre-deflationary phase, no table: a closed form `base >> (daa_score / blocks_per_two_years)` at 1 BPS. NO emission treasury | Medium. Simple maths, but `bps_history` and Crescendo per-BPS table rescaling (76 to 78) must be stripped rather than kept half-alive. | +| b2 | Subsidy parameters | `consensus/core/src/config/params.rs`; `consensus/core/src/config/bps.rs` | params 637 to 638 (mainnet `deflationary_phase_daa_score`, `pre_deflationary_phase_base_subsidy`), 342 to 344 (fields); bps 131 to 137 | 50 KAS per second split by BPS | New fields: `cap_sompi` (4e9 x 1e8), `halving_interval_blocks`, launch ramp (30 days) | Low. | +| b3 | Coinbase payee and split | `consensus/src/processes/coinbase.rs` | 97 to 142 (`expected_coinbase_transaction`), 144 to 220 (payload serialize, modify, deserialize) | One coinbase output per blue block to that block's miner, red blocks unpaid | 80/20 lottery/proving split: the 20% goes to the prover set recorded for the proven block, so the coinbase gains prover outputs. Fees: base fee burned in full, priority fee 65/15/15/5 with the 5% dev share per call frame (fee logic lives in the execution layer, not here) | High. Changes coinbase payload format and the "miner data" validation of every peer; the prover set must be known at coinbase construction time (20 to 60 s lag). | +| c1 | Difficulty adjustment (sampled DAA, KIP-4) | `consensus/src/processes/difficulty.rs` | 97 to 135 (`SampledDifficultyManager`), 166 to 198 (`calculate_difficulty_bits`), 211 to 223 (`calc_work`) | Average target of a 661-sample window (sample every 4 s), `new_target = avg x measured / expected`, clamp to `max_difficulty_target`, min window 150 samples | Kept as the retarget, but the hash speed jumps at every ~1 h epoch (bench: 35 to 48 Mhash/s across seeds on the same GPU) so the window must be short enough to track within an epoch, or the kernel generator must equalise cost per program. The 30-day vote-weight window (finality rule v2) is a new reader of DAA scores, not a retarget change | Medium. A per-epoch 30% hashrate step with a 44-minute window means roughly half an epoch at the wrong block rate. | +| c2 | DAA constants | `consensus/core/src/config/constants.rs` | 40 to 44 (`MAX_DIFFICULTY_TARGET` 2^255 - 1), 54 (`MIN_DIFFICULTY_WINDOW_SIZE` 150), 57 (`DIFFICULTY_WINDOW_DURATION` 2641 s), 60 (sample interval 4 s), 63 (sampled size 661) | As listed | Candidates: window 1800 s or shorter, keep 4 s samples; evaluate in simpa before deciding | Low. | +| d | Block header struct (vote key field) | `consensus/core/src/header.rs` | 154 to 172 (`Header`), 176 to 207 (`new_finalized`), 210 to 212 (`finalize`) | 12 fields, hash cached | Add `vote_key: [u8; 48]` (BLS12-381 G1 compressed public key; the finality vote weight is blue blocks per vote key over a flat 30-day DAA window, dust threshold 100 blocks). Every block carries it; equivocation evidence strips that key's weight for 30 days | High by spread, low by depth. Also edit: p2p wire `protocol/p2p/proto/p2p.proto:76 to 89` and `protocol/p2p/src/convert/header.rs:13, 45`; RPC `rpc/grpc/core/proto/rpc.proto:25` and `rpc/core/src/model/header.rs:85, 105, 248`; genesis headers `consensus/core/src/config/genesis.rs`; DB store `consensus/src/model/stores/headers.rs:24` (serde, re-sync needed); header mass. 48 bytes x 86,400 blocks/day = 4.1 MB/day extra. | +| e1 | Merge depth and finality depth constants | `consensus/core/src/config/constants.rs` | 70 (`FINALITY_DURATION` 43,200 s), 73 (`PRUNING_DURATION` 108,000 s), 81 (`MERGE_DEPTH_DURATION` 3600 s), 84 (`PRUNING_PROOF_M` 1000) | Scaled by BPS in `bps.rs:88 to 108` (at 10 BPS: merge 36,000 blocks, finality 432,000, pruning 1,080,000) | Merge depth kept at Kaspa's 3,600 s (design: fork choice is GHOSTDAG among tips through all certified checkpoints under merge-depth 3,600 s). Finality depth becomes a backstop only: the live finality is the 30-s certified checkpoint (2/3 of ACTIVE weight over a 2-hour presence window). Pruning depth must stay above the longest checkpoint gap | Medium. | +| e2 | Where depth is enforced | `consensus/src/processes/block_depth.rs`; `consensus/src/pipeline/header_processor/post_pow_validation.rs`; `consensus/src/pipeline/virtual_processor/processor.rs` | block_depth 51 to 69 (`calc_merge_depth_root`, `calc_finality_point`); post_pow 79 to 100 (`check_bounded_merge_depth`, kosherizing blues); processor 377 to 390 (`virtual_finality_point`) | Depth from blue score only | `virtual_finality_point` returns the latest certified checkpoint when one is newer than the depth point; virtual selection must refuse tips that do not descend from every certified checkpoint. The hidden-block n^2 penalty is NOT added (removed in review round 2, it broke DAG determinism) | High. Touches the virtual processor and finality-violation handling, which assume finality is a pure function of the DAG. | +| f1 | BPS and GHOSTDAG k | `consensus/core/src/config/bps.rs` | 24 (`TenBps`), 38 to 46 (k table: 1 BPS => 18, 10 BPS => 124), 49 to 54 (`target_time_per_block` = 1000 / BPS), 57 to 73 (max parents), 75 to 86 (mergeset limit), 119 to 121 (coinbase maturity) | k = 124 at 10 BPS (delta 0.01, network delay bound 5 s, `constants.rs:13 to 16`) | 1 block/s at launch: `Bps::<1>` gives k = 18, 1000 ms blocks, 10 max parents, mergeset limit 180, merge depth 3,600 blocks, finality 43,200 blocks, pruning 108,000 blocks, coinbase maturity 100 blocks | Low. This is the code path Kaspa mainnet ran before Crescendo. | +| f2 | Where the network picks its BPS | `consensus/core/src/config/params.rs` | 180 to 215 (`BlockrateParams`), 643 (mainnet `BlockrateParams::new::<10>()`), 645 (`pre_crescendo_target_time_per_block: 1000`), 648 (`crescendo_activation`), 699, 742, 783 (testnet, simnet, devnet) | 10 BPS everywhere, Crescendo and Toccata fork activations | Igneum mainnet params: `BlockrateParams::new::<1>()`, `ForkActivation::always()` for every past Kaspa fork (no history to replay), own genesis, own DNS seeders, own address prefix and ports (`consensus/core/src/network.rs:42 to 60, 238 to 252`) | Low to medium. The fork-activation plumbing (`bps_history`, `ForkedParam`) is woven through coinbase, difficulty and mass; strip it in one pass. | + +## Notes from the 3 Oct 2026 devnet run (see docs/bench-log.md) + +- Devnet and simnet in this commit run `BlockrateParams::new::<10>()`: 10 BPS, k 124, 100 ms blocks, merge depth 36,000 blocks, finality depth 432,000, pruning depth 1,080,000, coinbase maturity 200 (params.rs 783, 806 to 815). Simnet also sets `skip_proof_of_work: true` (params.rs 737) and allows 64 parents (742). +- Devnet genesis bits `0x1e21bc1c` (genesis.rs 193) means about 248,663 expected hashes per block, held fixed until 150 samples x 40 blocks = 6,000 blocks (difficulty.rs 170 to 177). +- `kaspad` ships no miner. `cli/src/modules/miner.rs` only spawns an external `kaspa-cpu-miner` binary (`daemon/src/cpu_miner/mod.rs:52 to 89`). The 3-node test used a 150-line miner built on `kaspa-pow::State` (real kHeavyHash), kept outside the repo. diff --git a/proto-vdf/.cargo/config.toml b/proto-vdf/.cargo/config.toml new file mode 100644 index 00000000..54cf8d74 --- /dev/null +++ b/proto-vdf/.cargo/config.toml @@ -0,0 +1,4 @@ +# System GMP from Homebrew (gmp 6.3.0). gmp-mpfr-sys with use-system-libs links against it. +[env] +LIBRARY_PATH = "/opt/homebrew/lib" +CPATH = "/opt/homebrew/include" diff --git a/proto-vdf/.gitignore b/proto-vdf/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/proto-vdf/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/proto-vdf/Cargo.toml b/proto-vdf/Cargo.toml new file mode 100644 index 00000000..1f784efb --- /dev/null +++ b/proto-vdf/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "igneum-vdf" +version = "0.1.0" +edition = "2021" +description = "Igneum prototype: Wesolowski VDF between the certified checkpoint and the hourly mining program seed" + +[[bin]] +name = "vdf" +path = "src/main.rs" + +[dependencies] +# Versions pinned so the crate builds with the Homebrew cargo 1.69 on this Mac (newer az/rug need edition 2024). +rug = { version = "=1.19.2", default-features = false, features = ["integer", "rand"] } +gmp-mpfr-sys = { version = "~1.5", default-features = false, features = ["use-system-libs"] } +az = "=1.2.1" +sha2 = "0.10" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/proto-vdf/src/classgroup.rs b/proto-vdf/src/classgroup.rs new file mode 100644 index 00000000..4d358821 --- /dev/null +++ b/proto-vdf/src/classgroup.rs @@ -0,0 +1,247 @@ +//! Imaginary quadratic class group Cl(D), D < 0, D = 1 mod 8, |D| prime. +//! +//! This is the production choice: no trusted setup, the group order is unknown to everyone. +//! Chia Network's chiavdf uses the same construction for its timelords +//! (vendor/chiavdf/src/create_discriminant.h derives D from a seed with HashPrime forcing +//! bits 0, 1, 2 and the top bit, so -D = 7 mod 8; vendor/chiavdf/src/vdf_new.h uses the +//! generator (2, 1, (1-D)/8) and the identity (1, 1, (1-D)/4)). +//! +//! Elements are reduced primitive positive-definite binary quadratic forms (a, b, c) with +//! b^2 - 4ac = D, -a < b <= a <= c, and b >= 0 when a == c. Reduced forms are unique per class, +//! so equality of reduced forms is equality in the group. +//! +//! Composition is Cohen, A Course in Computational Algebraic Number Theory, Algorithm 5.4.7. +//! Squaring uses the dedicated duplication formula also used by chiavdf's `square` in +//! vdf_new.h (valid because gcd(a, b) = 1 for every reduced form of a prime discriminant). +//! Neither NUCOMP nor NUDUPL (Shanks, Atkin) is implemented here; chiavdf's qfb_nudupl in +//! vendor/chiavdf/src/nucomp.h is the optimised production form. Expect this prototype to be +//! several times slower per squaring than chiavdf. Reduction is the textbook loop, not the +//! Pulmark fast reducer chiavdf uses. + +use crate::group::Group; +use crate::hash::{bytes_to_int, hash_prime, signed_to_bytes}; +use rug::Integer; +use std::cmp::Ordering; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Form { + pub a: Integer, + pub b: Integer, + pub c: Integer, +} + +pub struct ClassGroup { + /// Negative discriminant. + pub d: Integer, + pub d_bits: u32, +} + +impl ClassGroup { + /// D = -p where p is the `bits`-bit prime derived from `seed`, p = 7 mod 8. + /// Mirrors chiavdf CreateDiscriminant(seed, length) = -HashPrime(seed, length, {0,1,2,length-1}). + pub fn from_seed(seed: &[u8], bits: u32) -> ClassGroup { + let p = hash_prime(seed, bits, &[0, 1, 2, bits - 1]); + ClassGroup { d: -p, d_bits: bits } + } + + pub fn from_discriminant(d: Integer) -> ClassGroup { + assert!(d.cmp0() == Ordering::Less); + let d_bits = d.significant_bits(); + ClassGroup { d, d_bits } + } + + /// (a, b, c) from a, b with c = (b^2 - D) / 4a, reduced. None if not integral. + pub fn from_ab(&self, a: Integer, b: Integer) -> Option
{ + if a.cmp0() != Ordering::Greater { + return None; + } + let num = Integer::from(&b * &b) - &self.d; + let den = Integer::from(&a << 2u32); + if !num.is_divisible(&den) { + return None; + } + let c = num / den; + let mut f = Form { a, b, c }; + self.reduce(&mut f); + Some(f) + } + + /// The chiavdf generator (2, 1, (1-D)/8). Needs D = 1 mod 8. + pub fn generator(&self) -> Form { + self.from_ab(Integer::from(2), Integer::from(1)) + .expect("D must be 1 mod 8 for the generator (2, 1, (1-D)/8)") + } + + pub fn inverse(&self, f: &Form) -> Form { + let mut g = Form { a: f.a.clone(), b: Integer::from(-&f.b), c: f.c.clone() }; + self.reduce(&mut g); + g + } + + pub fn discriminant_of(f: &Form) -> Integer { + Integer::from(&f.b * &f.b) - Integer::from(&f.a * &f.c) * 4u32 + } + + fn normalize(f: &mut Form) { + // Want -a < b <= a. + let neg_a = Integer::from(-&f.a); + if neg_a < f.b && f.b <= f.a { + return; + } + // r = floor((a - b) / 2a); b' = b + 2ra; c' = c + r(ra + b). + let two_a = Integer::from(&f.a << 1u32); + let (r, _) = Integer::from(&f.a - &f.b).div_rem_floor(two_a); + let ra = Integer::from(&r * &f.a); + f.c += Integer::from(&ra + &f.b) * &r; + f.b += Integer::from(&ra << 1u32); + } + + pub fn is_reduced(f: &Form) -> bool { + let neg_a = Integer::from(-&f.a); + if !(neg_a < f.b && f.b <= f.a) { + return false; + } + match f.a.cmp(&f.c) { + Ordering::Greater => false, + Ordering::Equal => f.b.cmp0() != Ordering::Less, + Ordering::Less => true, + } + } + + pub fn reduce(&self, f: &mut Form) { + Self::normalize(f); + loop { + let swap = match f.a.cmp(&f.c) { + Ordering::Greater => true, + Ordering::Equal => f.b.cmp0() == Ordering::Less, + Ordering::Less => false, + }; + if !swap { + break; + } + // (a, b, c) <- (c, -b, a) + std::mem::swap(&mut f.a, &mut f.c); + f.b.neg_assign(); + Self::normalize(f); + } + } + + /// Cohen Algorithm 5.4.7. + pub fn compose(&self, f1: &Form, f2: &Form) -> Form { + let (f1, f2) = if f1.a > f2.a { (f2, f1) } else { (f1, f2) }; + // Step 1. + let s = Integer::from(&f1.b + &f2.b) >> 1u32; + let n = Integer::from(&f2.b - &s); + // Step 2: u a2 + v a1 = d. + let (y1, d) = if f1.a.is_divisible(&f2.a) { + (Integer::new(), f2.a.clone()) + } else { + let (d, u, _v) = f2.a.clone().extended_gcd(f1.a.clone(), Integer::new()); + (u, d) + }; + // Step 3: u s + v d = d1. + let (x2, y2, d1) = if s.is_divisible(&d) { + (Integer::new(), Integer::from(-1), d) + } else { + let (d1, u, v) = s.clone().extended_gcd(d, Integer::new()); + (u, -v, d1) + }; + // Step 4. + let v1 = Integer::from(&f1.a / &d1); + let v2 = Integer::from(&f2.a / &d1); + let mut r = Integer::from(&y1 * &y2) * &n - Integer::from(&x2 * &f2.c); + r.rem_euc_mut(&v1); + let v2r = Integer::from(&v2 * &r); + let b3 = Integer::from(&f2.b + Integer::from(&v2r << 1u32)); + let a3 = Integer::from(&v1 * &v2); + let c3 = (Integer::from(&f2.c * &d1) + Integer::from(&f2.b + &v2r) * &r) / &v1; + let mut out = Form { a: a3, b: b3, c: c3 }; + self.reduce(&mut out); + out + } + + /// Duplication, the formula chiavdf uses in vdf_new.h `square`: + /// with s b + t a = g = gcd(a, b) (g = 1 here), u = (c s) mod a, + /// A = a^2, B = b - 2au, C = u^2 - (bu - c)/a. Falls back to compose if g != 1. + pub fn square_form(&self, f: &mut Form) { + let (g, s, _t) = f.b.clone().extended_gcd(f.a.clone(), Integer::new()); + if g != 1 { + let r = self.compose(f, &f.clone()); + *f = r; + return; + } + let mut u = Integer::from(&f.c * &s); + u.rem_euc_mut(&f.a); + let au = Integer::from(&f.a * &u); + let bu_c = Integer::from(&f.b * &u) - &f.c; + let q = bu_c / &f.a; // exact: bu = c mod a + let a2 = Integer::from(&f.a * &f.a); + let b2 = Integer::from(&f.b - Integer::from(&au << 1u32)); + let c2 = Integer::from(&u * &u) - q; + f.a = a2; + f.b = b2; + f.c = c2; + self.reduce(f); + } + + fn coeff_width(&self) -> usize { + // Reduced forms have a, |b| <= sqrt(|D|/3) < 2^(d_bits/2 + 1). Keep a full d_bits + // width so unreduced but valid forms still round-trip during tests. + ((self.d_bits + 7) / 8) as usize + } +} + +impl Group for ClassGroup { + type Elem = Form; + + fn name(&self) -> String { + format!("class group, {}-bit prime discriminant", self.d_bits) + } + + fn identity(&self) -> Form { + self.from_ab(Integer::from(1), Integer::from(1)).expect("D = 1 mod 4") + } + + fn square(&self, x: &mut Form) { + self.square_form(x); + } + + fn mul(&self, a: &Form, b: &Form) -> Form { + self.compose(a, b) + } + + /// a and b, each as sign byte + fixed-width magnitude. c is recomputed. + fn serialize(&self, x: &Form) -> Vec { + let w = self.coeff_width(); + let mut out = signed_to_bytes(&x.a, w); + out.extend_from_slice(&signed_to_bytes(&x.b, w)); + out + } + + fn deserialize(&self, bytes: &[u8]) -> Option { + let w = self.coeff_width(); + if bytes.len() != 2 * (w + 1) { + return None; + } + let read = |s: &[u8]| -> Integer { + let mag = bytes_to_int(&s[1..]); + if s[0] == 1 { + -mag + } else { + mag + } + }; + let a = read(&bytes[..w + 1]); + let b = read(&bytes[w + 1..]); + let f = self.from_ab(a, b)?; + if self.is_valid(&f) { + Some(f) + } else { + None + } + } + + fn is_valid(&self, x: &Form) -> bool { + x.a.cmp0() == Ordering::Greater && Self::is_reduced(x) && Self::discriminant_of(x) == self.d + } +} diff --git a/proto-vdf/src/group.rs b/proto-vdf/src/group.rs new file mode 100644 index 00000000..4837bccb --- /dev/null +++ b/proto-vdf/src/group.rs @@ -0,0 +1,41 @@ +//! The group-of-unknown-order interface the VDF is generic over. + +use rug::Integer; +use std::fmt::Debug; + +pub trait Group: Sync { + type Elem: Clone + PartialEq + Debug + Send + Sync; + + fn name(&self) -> String; + fn identity(&self) -> Self::Elem; + /// x <- x^2, in place. This is the sequential step the delay is made of. + fn square(&self, x: &mut Self::Elem); + fn mul(&self, a: &Self::Elem, b: &Self::Elem) -> Self::Elem; + /// Canonical fixed-width encoding, used for hashing and for the proof wire format. + fn serialize(&self, x: &Self::Elem) -> Vec; + fn deserialize(&self, b: &[u8]) -> Option; + /// Membership and canonical-form check on an element received from the network. + fn is_valid(&self, x: &Self::Elem) -> bool; + + /// Left-to-right square and multiply. Used for the short exponents in verification. + fn pow(&self, x: &Self::Elem, e: &Integer) -> Self::Elem { + let mut r = self.identity(); + let bits = e.significant_bits(); + for i in (0..bits).rev() { + self.square(&mut r); + if e.get_bit(i) { + r = self.mul(&r, x); + } + } + r + } + + /// x^(2^k) by k squarings. + fn pow2k(&self, x: &Self::Elem, k: u64) -> Self::Elem { + let mut r = x.clone(); + for _ in 0..k { + self.square(&mut r); + } + r + } +} diff --git a/proto-vdf/src/hash.rs b/proto-vdf/src/hash.rs new file mode 100644 index 00000000..4b6fa2bc --- /dev/null +++ b/proto-vdf/src/hash.rs @@ -0,0 +1,84 @@ +//! Hash helpers: SHA-256 and hash-to-prime. +//! +//! `hash_prime` follows the shape of chiavdf's `HashPrime` +//! (vendor/chiavdf/src/proof_common.h): expand the seed with SHA-256 under a counter until +//! `bits` bits are filled, force the requested bits on, force odd, and retry until the +//! candidate passes a probable-prime test. The counter is a 64-bit big-endian suffix here, +//! where chiavdf increments the seed bytes in place. Same idea, not byte-compatible. + +use rug::integer::{IsPrime, Order}; +use rug::Integer; +use sha2::{Digest, Sha256}; + +pub const PRIME_REPS: u32 = 30; + +pub fn sha256(parts: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +/// Deterministic prime of exactly `bits` bits derived from `seed`. +/// Bits listed in `set_bits` are forced to 1 (chiavdf passes {0, 1, 2, bits-1} for a +/// discriminant, so that p = 7 mod 8 and the top bit is set, and {bits-1} for the +/// Fiat-Shamir prime). +pub fn hash_prime(seed: &[u8], bits: u32, set_bits: &[u32]) -> Integer { + assert!(bits % 8 == 0 && bits >= 64); + let nbytes = (bits / 8) as usize; + let mut ctr: u64 = 0; + loop { + let mut blob: Vec = Vec::with_capacity(nbytes + 32); + while blob.len() < nbytes { + let h = sha256(&[seed, &ctr.to_be_bytes()]); + ctr += 1; + blob.extend_from_slice(&h); + } + blob.truncate(nbytes); + let mut p = Integer::from_digits(&blob, Order::MsfBe); + for &b in set_bits { + p.set_bit(b, true); + } + p.set_bit(0, true); + if p.is_probably_prime(PRIME_REPS) != IsPrime::No { + return p; + } + } +} + +/// Fixed-width big-endian encoding of a non-negative integer. +pub fn int_to_bytes(x: &Integer, width: usize) -> Vec { + assert!(x.cmp0() != std::cmp::Ordering::Less); + let d = x.to_digits::(Order::MsfBe); + assert!(d.len() <= width, "integer wider than {} bytes", width); + let mut out = vec![0u8; width - d.len()]; + out.extend_from_slice(&d); + out +} + +/// Signed fixed-width encoding: one sign byte (0 or 1) then the magnitude. +pub fn signed_to_bytes(x: &Integer, width: usize) -> Vec { + let mut out = Vec::with_capacity(width + 1); + out.push(if x.cmp0() == std::cmp::Ordering::Less { 1 } else { 0 }); + let mag = Integer::from(x.abs_ref()); + out.extend_from_slice(&int_to_bytes(&mag, width)); + out +} + +pub fn bytes_to_int(b: &[u8]) -> Integer { + Integer::from_digits(b, Order::MsfBe) +} + +pub fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{:02x}", x)).collect() +} + +pub fn from_hex(s: &str) -> Option> { + if s.len() % 2 != 0 { + return None; + } + (0..s.len() / 2) + .map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()) + .collect() +} diff --git a/proto-vdf/src/rsa.rs b/proto-vdf/src/rsa.rs new file mode 100644 index 00000000..4ab9e955 --- /dev/null +++ b/proto-vdf/src/rsa.rs @@ -0,0 +1,108 @@ +//! RSA-style group Z_N^* with a 2048-bit modulus. +//! +//! TRUSTED-SETUP STAND-IN. NOT FOR PRODUCTION. +//! Whoever knows the factors of N can compute x^(2^T) in two short exponentiations +//! (reduce 2^T mod phi(N)) and skip the delay entirely. In this prototype the factors are +//! derived from a PUBLIC seed string, so the trapdoor is public by construction. The group +//! exists here only to measure squaring speed and to exercise the Wesolowski code against a +//! second group. The production group is the class group in `classgroup.rs`. +//! +//! Soundness note for the record: Wesolowski over Z_N^* needs the low-order assumption, which +//! fails for -1 (order 2). Real deployments work in QR_N or in Z_N^*/{+-1}. We map inputs into +//! QR_N by squaring and do not canonicalise signs, which is enough for timing. + +use crate::group::Group; +use crate::hash::{bytes_to_int, int_to_bytes, sha256}; +use rug::integer::Order; +use rug::rand::RandState; +use rug::Integer; + +pub const MODULUS_BITS: u32 = 2048; + +pub struct RsaGroup { + pub n: Integer, +} + +impl RsaGroup { + /// Deterministic modulus from a public seed. See the module note: the factors are public. + pub fn from_public_seed(seed: &str) -> RsaGroup { + let mut rs = RandState::new_mersenne_twister(); + let s = sha256(&[b"igneum-vdf-rsa-standin", seed.as_bytes()]); + rs.seed(&Integer::from_digits(&s, Order::MsfBe)); + let half = MODULUS_BITS / 2; + let mut gen_prime = |rs: &mut RandState| -> Integer { + let mut p = Integer::from(Integer::random_bits(half, rs)); + p.set_bit(half - 1, true); + p.set_bit(half - 2, true); + p.set_bit(0, true); + p.next_prime_mut(); + p + }; + let p = gen_prime(&mut rs); + let mut q = gen_prime(&mut rs); + while q == p { + q = gen_prime(&mut rs); + } + let n = Integer::from(&p * &q); + assert_eq!(n.significant_bits(), MODULUS_BITS); + RsaGroup { n } + } + + /// Hash to an element of QR_N. + pub fn hash_to_elem(&self, data: &[u8]) -> Integer { + let mut acc = Vec::new(); + let mut i: u32 = 0; + while acc.len() * 8 < MODULUS_BITS as usize { + acc.extend_from_slice(&sha256(&[b"igneum-vdf-rsa-x", data, &i.to_be_bytes()])); + i += 1; + } + let mut x = bytes_to_int(&acc); + x %= &self.n; + x.square_mut(); + x %= &self.n; + x + } +} + +impl Group for RsaGroup { + type Elem = Integer; + + fn name(&self) -> String { + format!("RSA-{} stand-in (trusted setup, public trapdoor)", MODULUS_BITS) + } + + fn identity(&self) -> Integer { + Integer::from(1) + } + + fn square(&self, x: &mut Integer) { + x.square_mut(); + *x %= &self.n; + } + + fn mul(&self, a: &Integer, b: &Integer) -> Integer { + let mut r = Integer::from(a * b); + r %= &self.n; + r + } + + fn serialize(&self, x: &Integer) -> Vec { + int_to_bytes(x, (MODULUS_BITS / 8) as usize) + } + + fn deserialize(&self, b: &[u8]) -> Option { + if b.len() != (MODULUS_BITS / 8) as usize { + return None; + } + let x = bytes_to_int(b); + if self.is_valid(&x) { + Some(x) + } else { + None + } + } + + fn is_valid(&self, x: &Integer) -> bool { + x.cmp0() == std::cmp::Ordering::Greater && *x < self.n + } +} diff --git a/proto-vdf/src/wesolowski.rs b/proto-vdf/src/wesolowski.rs new file mode 100644 index 00000000..19fa198a --- /dev/null +++ b/proto-vdf/src/wesolowski.rs @@ -0,0 +1,202 @@ +//! Wesolowski VDF (Efficient Verifiable Delay Functions, EUROCRYPT 2019), generic over a +//! group of unknown order. +//! +//! eval: y = x^(2^T) by T sequential squarings. Checkpoints x^(2^(S j)) are kept every S +//! squarings so the proof can be built in about T/kappa group operations. +//! prove: l = HashPrime(x, y, T) (Fiat-Shamir, 256-bit prime), pi = x^floor(2^T / l). +//! The quotient is consumed in kappa-bit digits b_i = floor(2^kappa (2^(T-kappa(i+1)) mod l) / l), +//! the same digit formula as chiavdf's Prover::GetBlock (vendor/chiavdf/src/prover_impl.hpp), +//! and digits are bucketed by value per residue class m mod gamma, so the cost is +//! T/kappa multiplications plus gamma 2^(kappa+1) for the bucket combine. +//! Residue classes are independent, so they are spread over threads. +//! verify: r = 2^T mod l, accept iff pi^l x^r == y. Two short exponentiations. + +use crate::group::Group; +use crate::hash::hash_prime; +use rug::Integer; +use std::time::Instant; + +pub const L_BITS: u32 = 256; + +#[derive(Clone, Debug)] +pub struct Proof { + pub y: E, + pub pi: E, +} + +pub struct EvalOutput { + pub y: E, + /// checkpoints[j] = x^(2^(spacing j)), j = 0 ..= T / spacing. checkpoints[0] = x. + pub checkpoints: Vec, + pub spacing: u64, + pub t: u64, + pub seconds: f64, +} + +/// Fiat-Shamir challenge prime. Domain separated and bound to x, y and T. +pub fn challenge_prime(g: &G, x: &G::Elem, y: &G::Elem, t: u64) -> Integer { + let mut seed = Vec::new(); + seed.extend_from_slice(b"igneum-vdf-challenge"); + seed.extend_from_slice(&g.serialize(x)); + seed.extend_from_slice(&g.serialize(y)); + seed.extend_from_slice(&t.to_be_bytes()); + hash_prime(&seed, L_BITS, &[L_BITS - 1]) +} + +/// Choose a checkpoint spacing that keeps at most `max_checkpoints` elements in memory. +pub fn choose_spacing(t: u64, max_checkpoints: u64) -> u64 { + let s = (t + max_checkpoints - 1) / max_checkpoints; + s.max(1) +} + +pub fn eval(g: &G, x: &G::Elem, t: u64, spacing: u64) -> EvalOutput { + let start = Instant::now(); + let mut cur = x.clone(); + let mut checkpoints = Vec::with_capacity((t / spacing + 1) as usize); + checkpoints.push(cur.clone()); + for i in 1..=t { + g.square(&mut cur); + if i % spacing == 0 { + checkpoints.push(cur.clone()); + } + } + EvalOutput { y: cur, checkpoints, spacing, t, seconds: start.elapsed().as_secs_f64() } +} + +/// Build the Wesolowski proof from the eval output. +/// `kappa` is the digit width in bits (8 to 16 is sensible); gamma = spacing / kappa. +pub fn prove(g: &G, x: &G::Elem, ev: &EvalOutput, kappa: u32, threads: usize) -> Proof { + let t = ev.t; + let l = challenge_prime(g, x, &ev.y, t); + let pi = compute_pi(g, &ev.checkpoints, ev.spacing, t, &l, kappa, threads); + Proof { y: ev.y.clone(), pi } +} + +fn compute_pi( + g: &G, + checkpoints: &[G::Elem], + spacing: u64, + t: u64, + l: &Integer, + kappa: u32, + threads: usize, +) -> G::Elem { + assert!(kappa >= 1 && kappa <= 20); + let kappa64 = kappa as u64; + // Digit positions 0 .. n_full have exponent T - kappa(i+1) >= 0. Higher digits are 0 + // because 2^(T mod kappa) < l. + let n_full = t / kappa64; + // gamma digits per checkpoint interval. If spacing is not a multiple of kappa we fall + // back to gamma = 1 with a stride of kappa, which only works when spacing == kappa. + let gamma = spacing / kappa64; + assert!(gamma >= 1 && gamma * kappa64 == spacing, "spacing must be a multiple of kappa"); + let bucket_count = 1usize << kappa; + + // inv = 2^(-kappa gamma) mod l, steps the digit residue from index i to i + gamma. + let two = Integer::from(2); + let step = Integer::from(spacing); + let inv = two.clone().pow_mod(&step, l).unwrap().invert(l).unwrap(); + + // z_m = prod_j C_j^(b_{gamma j + m}) for each residue class m. + let compute_z = |m: u64| -> G::Elem { + if m >= n_full { + return g.identity(); + } + let mut buckets: Vec> = vec![None; bucket_count]; + // R = 2^(T - kappa(m+1)) mod l + let e0 = Integer::from(t - kappa64 * (m + 1)); + let mut r = two.clone().pow_mod(&e0, l).unwrap(); + let mut i = m; + let mut j = 0usize; + while i < n_full { + let digit = Integer::from(&r << kappa).div_rem_floor(l.clone()).0.to_usize().unwrap(); + if digit != 0 { + let cj = &checkpoints[j]; + buckets[digit] = Some(match &buckets[digit] { + None => cj.clone(), + Some(p) => g.mul(p, cj), + }); + } + r *= &inv; + r %= l; + i += gamma; + j += 1; + } + // z = prod_d P_d^d via running products. + let mut running = g.identity(); + let mut acc = g.identity(); + for d in (1..bucket_count).rev() { + if let Some(p) = &buckets[d] { + running = g.mul(&running, p); + } + acc = g.mul(&acc, &running); + } + acc + }; + + let gamma_usize = gamma as usize; + let mut zs: Vec> = vec![None; gamma_usize]; + let threads = threads.max(1).min(gamma_usize); + if threads == 1 { + for m in 0..gamma_usize { + zs[m] = Some(compute_z(m as u64)); + } + } else { + let chunk = (gamma_usize + threads - 1) / threads; + let results: Vec> = std::thread::scope(|sc| { + let handles: Vec<_> = (0..threads) + .map(|tix| { + let lo = tix * chunk; + let hi = ((tix + 1) * chunk).min(gamma_usize); + let cz = &compute_z; + sc.spawn(move || (lo..hi).map(|m| cz(m as u64)).collect::>()) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + let mut m = 0; + for v in results { + for z in v { + zs[m] = Some(z); + m += 1; + } + } + } + + // Horner: pi = prod_m z_m^(2^(kappa m)). + let mut pi = g.identity(); + for m in (0..gamma_usize).rev() { + for _ in 0..kappa { + g.square(&mut pi); + } + pi = g.mul(&pi, zs[m].as_ref().unwrap()); + } + pi +} + +/// Reference proof by the plain O(T) long-division algorithm. Used in self-tests only. +pub fn prove_naive(g: &G, x: &G::Elem, y: &G::Elem, t: u64) -> Proof { + let l = challenge_prime(g, x, y, t); + let mut r = Integer::from(1); + let mut pi = g.identity(); + for _ in 0..t { + let two_r = Integer::from(&r << 1u32); + let (b, rem) = two_r.div_rem_floor(l.clone()); + r = rem; + g.square(&mut pi); + if b == 1 { + pi = g.mul(&pi, x); + } + } + Proof { y: y.clone(), pi } +} + +pub fn verify(g: &G, x: &G::Elem, proof: &Proof, t: u64) -> bool { + if !g.is_valid(x) || !g.is_valid(&proof.y) || !g.is_valid(&proof.pi) { + return false; + } + let l = challenge_prime(g, x, &proof.y, t); + let r = Integer::from(2).pow_mod(&Integer::from(t), &l).unwrap(); + let lhs = g.mul(&g.pow(&proof.pi, &l), &g.pow(x, &r)); + lhs == proof.y +} diff --git a/site/index.html b/site/index.html index a16668d1..7f8fc5d5 100644 --- a/site/index.html +++ b/site/index.html @@ -274,7 +274,7 @@ footer .wrap{padding-block:48px 32px}
rollup ยท fault proofat testnet
IGN burned from jobsat launch
-

The same cards that secure Igneum sell proofs to rollups and bridges. 10% of every job fee is burned.

+

The same cards that secure Igneum sell proofs to rollups and bridges. Jobs through Igneum's own market are paid in IGN and 10% of each fee is burned.

@@ -291,13 +291,13 @@ footer .wrap{padding-block:48px 32px} PropertyRandomX, MoneroIgneum Hardware it is built forCPUs. GPUs run it badly on purposeGPUs. Any card, any vendor. Bit-exact on Apple and NVIDIA, measured - Random programPer hash, interpreted in a virtual machinePer hour, compiled to native GPU code, with a per-hash random data path + Random programPer hash, interpreted in a virtual machinePer hour, compiled to native GPU code, with a per-hash random data path. ProgPoW randomised the maths inside a fixed shape; Igneum regenerates the whole program DatasetAbout 2 GB, the same size since 2019, approximate2 GB at genesis, growing every year past any chip's memory Light verification256 MB cache on a CPU, milliseconds256 MB cache on a CPU, one warp under 10 ms, the measured gate Changes over timeNone. A fixed design, unchanged for seven yearsAutomatic era draws and a reserve of instruction families that unlock by height. Nobody touches it Seed grindingNot applicable, the program comes from the hash inputClosed by a verifiable delay between seed and program Useful workNone. Hashing onlyThe same card proves every block and sells proofs to other chains - Track recordNo chip in seven yearsZero years. Every number above is measured, published, and reproducible from the repository + Track recordNo chip in seven yearsZero years. Every number above is measured and logged with the commands that produced it, and the repository opens with the public benchmark in January 2027 diff --git a/site/litepaper.html b/site/litepaper.html index 3e3112bb..c45314b7 100644 --- a/site/litepaper.html +++ b/site/litepaper.html @@ -173,12 +173,12 @@ body.all .pager{display:none}
- + - + - +
FirstClosest precedentWhat Igneum adds
A mining program that regenerates itself, for GPUsRandomX does it for CPUs on Monero, since 2019The GPU version. Designed, discussed, never shipped
A mining program that regenerates itself, for GPUsRandomX does it for CPUs on Monero, since 2019Whole-program regeneration on GPUs. ProgPoW and Ravencoin's KAWPOW randomised the maths inside a fixed program shape in 2020; Igneum regenerates the whole program hourly over a growing dataset, with automatic eras
The mining card does paid, useful, verifiable workPrimecoin's prime chains in 2013 were not useful. Aleo's proving-as-consensus centralisedProving is useful, verifiable in milliseconds, and kept apart from the lottery
A proof-of-work chain where every block is provenzkEVMs exist only as rollups on top of proof-of-stake EthereumEthereum apps on a GPU-mined chain whose state cannot be wrong
A proof-of-work chain where every block is provenzkEVMs exist only as rollups on proof-of-stake Ethereum. Conflux has run GPU-mined EVM apps on a DAG since 2020, without proofsProven state on a proof-of-work base layer, produced by the miners themselves
Finality held by miners and immune to hour-long rentalsDecred votes with stake. Horizen penalises hidden chains. Kaspa limits depthSustained-mining weight: hashrate that appeared today has no vote
100% of emission to the people running the hardwareKaspa's fair launch, with no utility. Zcash and Decred fund developers from emissionFair launch, utility, and a development fund paid by outsiders and spent by miners
A chain your browser verifies by itselfLight clients trust a committeeOne proof plus one locked checkpoint, no trust
A chain your browser verifies by itselfLight clients trust a committeeAt launch, one execution proof plus the votes on the latest locked checkpoint. The single-proof client that also proves canonicity is phase two and the roadmap says so
GPU mining lost its home in 2022. Igneum is the first chain built so that it can never be taken away again: not by a chip, not by a merge to proof of stake, not by a rental attack, and not by a foundation.
@@ -188,7 +188,7 @@ body.all .pager{display:none}

The problem

Three things are wrong at once, and Igneum is built where they meet.

GPU mining has no home

-

Ethereum left proof of work in 2022 and stranded the largest fleet of general-purpose compute ever assembled. Every chain that tried to take it in since has either been captured by specialised chips within two years, as Kaspa was, or has stayed too small to pay the power bill. Miners burn electricity on a lottery and are paid in inflation. When the price falls they switch off, and the chain's security goes with them.

+

Ethereum left proof of work in 2022 and stranded the largest fleet of general-purpose compute ever assembled. Every chain that took it in since has either been taken over by chips, as Kaspa was within about two years, or has stayed small, as Ergo and Ravencoin have. Miners burn electricity on a lottery and are paid in inflation. When the price falls they switch off, and the chain's security goes with them.

Proving is centralised

Rollups, bridges and soon Ethereum itself need zero-knowledge proofs of every batch and every block. Today those proofs come from a few private GPU clusters run by the rollup teams or by a handful of proving companies. The work is a commodity, a proof is correct or it is not, and the cheapest correct proof should win. It does not, because the people with the cheapest GPUs are not in the market.

Small proof-of-work chains get attacked

@@ -278,10 +278,10 @@ body.all .pager{display:none} Changes over timeNone. A fixed design, unchanged for seven yearsAutomatic era draws and a reserve of instruction families that unlock by height. Nobody touches it Seed grindingNot applicable, the program comes from the hash inputClosed by a verifiable delay between seed and program Useful workNone. Hashing onlyThe same card proves every block and sells proofs to other chains - Track recordNo chip in seven yearsZero years. Every number above is measured, published, and reproducible from the repository + Track recordNo chip in seven yearsZero years. Every number above is measured and logged with the commands that produced it, and the repository opens with the public benchmark in January 2027 -

Measured so far: the same hourly program, generated on an Apple M5 Max, compiled by Apple's Metal and NVIDIA's CUDA on an RTX 5090, produced identical hashes on both, 192 of 192 across two programs. On a 1 GB dataset the 5090 ran at about 228 million hashes a second and the Mac at about 45 million, both bound by random memory access rather than arithmetic. Inside the 5090's 96 MB cache the same program ran nearly six times faster, which is why the dataset starts at 2 GB and grows. AMD is the next test.

+

Measured so far: the same hourly program, generated on an Apple M5 Max, compiled by Apple's Metal and NVIDIA's CUDA on an RTX 5090, produced identical hashes on both, 192 of 192 across two programs. On a 1 GB dataset the 5090 ran at about 228 million hashes a second and the Mac at about 45 million, both bound by random memory access rather than arithmetic. Those are prototype figures: the prototype's dataset is still a simple formula a miner could compute instead of loading, which the next build replaces with a 256 MB cache construction, so the rates will change and are not mining rates. Inside the 5090's 96 MB cache the same program ran nearly six times faster, which is why the dataset starts at 2 GB and grows. AMD is the next test.

@@ -290,9 +290,9 @@ body.all .pager{display:none}

How a block gets proven

Blocks carry transactions only and make no claim about state. Every node executes the ordered transactions natively at once, so users see their transaction land in about a second. The execution is then split into shards of a fixed proving cost. Miners claim shards with a small bond, prove them on consumer cards, and the shard proofs are folded by recursive aggregation into one proof for the block. That proof lands on-chain within about a minute at launch. Because the proof computes the state from the ordered sequence, a block with a wrong state cannot exist. Invalid transactions are skipped by rule, the way Kaspa skips conflicting spends.

The proving budget

-

Gas prices execution. Proving cost is a different number, so Igneum meters it separately: every transaction pays in both dimensions, and each block has a proving-cost budget set in consensus from measured prover throughput. A transaction that is cheap to run and expensive to prove pays for what it costs the provers. Shard size is set so a 12 GB card proves one shard in about 20 seconds, measured on a mid-range card before launch and raised by schedule as hardware improves. The proof system is hash-based, which is what runs on consumer cards, and sits behind a versioned interface, so Igneum can adopt a better proof system when one exists by a miner-signalled release, and runs for ever on the current one if none is adopted.

+

Gas prices execution. Proving cost is a different number, so Igneum meters it separately: every transaction pays in both dimensions, and each block has a proving-cost budget set in consensus from measured prover throughput. A transaction that is cheap to run and expensive to prove pays for what it costs the provers. Shard size will be set so a 12 GB card proves one shard in about 20 seconds. That number is the first gate on the roadmap and has not been measured yet; once it has, the budget rises by schedule as hardware improves. The proof system is hash-based, which is what runs on consumer cards, and sits behind a versioned interface, so Igneum can adopt a better proof system when one exists by a miner-signalled release, and runs for ever on the current one if none is adopted.

Proving for everyone else

-

The same miners accept proving jobs from other chains. Rollups post a job, a miner wins it, proves it, and is paid. The job market is permissionless, the Igneum miner client also bids on other proving networks and takes the best price, and 10% of every job fee is burned. The proving market is small today. Igneum does not depend on it. No other proof-of-work chain has a seat in it.

+

The same miners accept proving jobs from other chains. Rollups post a job, a miner wins it, proves it, and is paid. The job market is permissionless. Jobs taken through Igneum's own market are paid in IGN and 10% of each fee is burned. The Igneum miner client also bids on other proving networks, where jobs are paid in those networks' currencies, and takes the best price. The proving market is small today. Igneum does not depend on it. No other proof-of-work chain has a seat in it.

@@ -300,7 +300,7 @@ body.all .pager{display:none}

Speed

Igneum orders blocks with GHOSTDAG, the BlockDAG consensus proven on Kaspa. Blocks arrive in parallel and are ordered rather than orphaned, so the chain runs at one block a second at launch with scheduled steps to four and ten. A transaction is included in about one second, against twelve on Ethereum, and locked in about two minutes against roughly thirteen. Emission is paid per unit of difficulty-adjusted work, never per block, so a faster chain never means more coins.

Finality

-

Every 30 seconds of chain a checkpoint forms, deep enough past the tip that the DAG will not reorder it. Every miner with at least 100 blocks in the last 30 days signs it, and the checkpoint locks when signatures representing two thirds of the active mining weight arrive. A locked checkpoint overrides the heaviest chain, so no amount of fresh hashrate can reorganise past it.

+

Every 30 seconds of chain a checkpoint forms, deep enough past the tip that the DAG will not reorder it. Every miner with at least 100 blocks in the last 30 days signs it, and the checkpoint locks when signatures representing two thirds of the active mining weight arrive. Once a checkpoint is locked it overrides the heaviest chain, so no amount of fresh hashrate can reorganise past it. In the chain's first month the weights behind those locks are thin, because nobody has a long history yet, and the chain leans on proof of work and the one-hour depth limit the way every new proof-of-work chain does.

The word sustained is the whole defence. Block rewards go to whoever mines, new or old. The right to lock history is earned.

A miner's vote weight is simply the blocks it has mined over the trailing 30 days, measured by work, so splitting into many keys buys nothing and joining a pool costs nothing. Hashrate that arrived today holds almost none of it. Even an attacker who brought the whole network's hashrate would need ten days of mining in public to hold a third of the weight, and twenty days to hold two thirds. At 51% of the network they never reach two thirds at all while the honest miners keep mining. Rental is priced by the hour. The only route left is to drive honest miners off the chain and hold two thirds for a month on the public hashrate charts, which is the same limit Bitcoin lives with, with a month's warning attached.

Two further rules close the gaps. Only miners who are present count: a key that stops signing drops out of the denominator within two hours, so a silent minority cannot freeze finality and a lock never waits for miners who have left. And Kaspa's one-hour merge-depth bound limits any reorganisation beneath the latest lock. Signing two different checkpoints at the same height is equivocation, provable by anyone, and it strips the key of its vote for 30 days.

@@ -427,8 +427,8 @@ body.all .pager{display:none}

Questions miners ask

-

Kaspa said ASIC resistant too, and IceRiver shipped a chip in eighteen months.

-

Kaspa's hash was one fixed function, simple enough to put on silicon. Igneum's program is different every hour, its dataset grows past any fixed memory, and its program space widens every era, with no human involved. In January 2027 the benchmark tool is public, so you run it on your own card and post the number to a leaderboard by card model. A standing bounty pays anyone who can show a chip design that beats a GPU by more than 2x. And if a chip ever appears, miners are the ones who signal the response.

+

Kaspa was GPU-mined too, and IceRiver shipped a chip within two years.

+

Kaspa never promised chip resistance, and its hash was one fixed function, simple enough to put on silicon. Igneum's program is different every hour, its dataset grows past any fixed memory, and its program space widens every era, with no human involved. In January 2027 the benchmark tool is public, so you run it on your own card and post the number to a leaderboard by card model. A standing bounty pays anyone who can show a chip design that beats a GPU by more than 2x. And if a chip ever appears, miners are the ones who signal the response.

Finality weighted by mining history is new. New gets attacked.

Correct, and it is the first thing the external review is paid to break. The specification is public, the review is gate 3 with named reviewers and a bounty, and the chain runs on plain GHOSTDAG without it, so the rule can be fixed without stopping the chain.

Who are you?