iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 549ab9f1ff1158318920382e17a4d973816d8b9f
parent 22aca6b73da3d3f687db476e91abc0ffba8377a3
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed,  9 Sep 2026 21:44:29 +0200

fix(consensus): pin candidate genesis

Diffstat:
Mdocs/protocol.md | 1+
Mdocs/security-audit-2026-09-09.md | 33+++++++++++++++++----------------
Msrc/adapters/p2p/fetch.rs | 20++++++++++++++++++--
Msrc/adapters/p2p/process.rs | 42++++++++++++++++++++++--------------------
Msrc/adapters/p2p/tests.rs | 29++++++++++++-----------------
Msrc/app.rs | 28++++++++++++++++++++++++++--
Msrc/main.rs | 22+++++++++++++++++++---
Msrc/main_tests.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
8 files changed, 192 insertions(+), 79 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -17,6 +17,7 @@ This is still experimental. The rules below describe the frozen mainnet-candidat The current mainnet-candidate parameter set is intentionally close to Bitcoin where that is useful for operator expectations: - P2P network ID: `iuna-mainnet-candidate`; +- genesis hash: `3d677cd7ced1c04d3a276cbee7ea38076e34ac65f18a2c9b8286a4872d986a9a`; - protocol version: `2`; - launch profile ID: `iuna-mainnet-candidate`; - launch profile hash: `eb2f67e9d735474859ceb1fe124fe270977214f6e2f4cd855a4d8c3b5ecac558`; diff --git a/docs/security-audit-2026-09-09.md b/docs/security-audit-2026-09-09.md @@ -20,7 +20,7 @@ found in the reviewed validation paths. | --- | --- | ---: | --- | | IUNA-2026-001 | High | Effectively EUR 0 from one public IP | Patched | | IUNA-2026-002 | High over chain lifetime | A basic host and bandwidth; well below EUR 100/month | Patched | -| IUNA-2026-003 | High during first sync | Below EUR 10,000 when DNS/routing/bootstrap access is available | Open promotion blocker | +| IUNA-2026-003 | High during first sync | Below EUR 10,000 when DNS/routing/bootstrap access is available | Patched | | IUNA-2026-004 | Medium | 10,000 minimum-fee transactions; protocol value likely far below EUR 10,000 | Open hardening item | ## IUNA-2026-001 — one source could exhaust every Stratum session @@ -95,20 +95,21 @@ Impact: a new operator whose DNS, route, configuration, or only bootstrap peer is controlled can be placed on a valid but attacker-created chain. Subsequent same-genesis validation will keep that node isolated from the real candidate. -The repository manifest records genesis +The production database and repository manifest both record genesis `3d677cd7ced1c04d3a276cbee7ea38076e34ac65f18a2c9b8286a4872d986a9a`, but the -manifest still describes v0.4.18 while this audit targets v0.4.28. Hard-coding -that value without first reconciling and signing the release manifest could -lock in stale governance data, so no automatic patch was applied. +manifest still describes v0.4.18 while this audit targets v0.4.28. -Required before promotion: +Patch: + +- pin the production genesis in the mainnet-candidate binary; +- reject a mismatched genesis during direct join, setup-placeholder bootstrap, + and persisted candidate-chain startup; +- prevent `--genesis` from creating a second mainnet-candidate chain while + leaving local testnet genesis creation available. -1. Publish and sign an updated candidate manifest. -2. Pin the approved genesis hash (and preferably one finalized checkpoint) in - the mainnet-candidate binary. -3. Require fresh nodes to match it before downloading or adopting block pages. -4. Pin bootstrap node identities or obtain the checkpoint from at least two - independently operated sources. +Residual hardening before promotion: publish and sign an updated candidate +manifest, pin a finalized checkpoint, and pin bootstrap identities or obtain +the checkpoint from at least two independently operated sources. ## IUNA-2026-004 — full mempool rejects higher-fee transactions @@ -149,7 +150,7 @@ therefore not claimed by this report. ## Promotion recommendation -Do not promote to mainnet until IUNA-2026-003 is resolved, the complete release -gate is run on the exact final revision, and the custom VDF plus economic -finality assumptions receive independent review. IUNA-2026-004 should be fixed -before exposing a high-value public transaction network. +Do not promote to mainnet until the complete release gate is run on the exact +final revision, the candidate manifest is updated, and the custom VDF plus +economic finality assumptions receive independent review. IUNA-2026-004 should +be fixed before exposing a high-value public transaction network. diff --git a/src/adapters/p2p/fetch.rs b/src/adapters/p2p/fetch.rs @@ -7,8 +7,11 @@ use tokio::{ }; use crate::{ - app::{ChainBootstrap, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, ProtocolHello, now_ms}, - domain::{Block, ChainSnapshot, Ledger, verify_vdf}, + app::{ + ChainBootstrap, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, ProtocolHello, now_ms, + validate_network_genesis, + }, + domain::{Block, ChainSnapshot, LaunchProfile, Ledger, verify_vdf}, }; use super::{ @@ -102,6 +105,7 @@ pub async fn fetch_snapshot_with_announcement( write_envelope(&mut writer, &join_client_hello()).await?; write_envelope(&mut writer, &GossipEnvelope::ChainBootstrapRequest).await?; let bootstrap = read_join_bootstrap_response(peer, &mut reader).await?; + validate_bootstrap_genesis(&LaunchProfile::default().profile_id, &bootstrap)?; let mut snapshot = ChainSnapshot { genesis_allocations: bootstrap.genesis_allocations, @@ -205,9 +209,11 @@ fn is_join_control_envelope(envelope: &GossipEnvelope) -> bool { } pub(super) async fn validate_chain_bootstrap( + expected_profile_id: &str, bootstrap: ChainBootstrap, now_ms: u64, ) -> Result<Ledger> { + validate_bootstrap_genesis(expected_profile_id, &bootstrap)?; let snapshot = ChainSnapshot { genesis_allocations: bootstrap.genesis_allocations, vdf_rounds: bootstrap.vdf_rounds, @@ -219,6 +225,16 @@ pub(super) async fn validate_chain_bootstrap( .context("chain bootstrap adoption worker failed")? } +fn validate_bootstrap_genesis(expected_profile_id: &str, bootstrap: &ChainBootstrap) -> Result<()> { + if bootstrap.launch_profile.profile_id != expected_profile_id { + anyhow::bail!( + "chain bootstrap profile {} does not match expected profile {expected_profile_id}", + bootstrap.launch_profile.profile_id + ); + } + validate_network_genesis(expected_profile_id, &bootstrap.genesis_block.hash) +} + pub(super) async fn validate_blocks_extension( mut ledger: Ledger, blocks: Vec<Block>, diff --git a/src/adapters/p2p/process.rs b/src/adapters/p2p/process.rs @@ -255,14 +255,13 @@ pub(super) async fn process_envelope( } GossipEnvelope::ChainBootstrap(bootstrap) => { let sync_generation = network.sync_generation(); - let base_tip = network - .inner - .node - .lock() - .await - .ledger() - .tip_hash() - .to_string(); + let (base_tip, expected_profile_id) = { + let node = network.inner.node.lock().await; + ( + node.ledger().tip_hash().to_string(), + node.ledger().launch_profile().profile_id.clone(), + ) + }; let validation_key = chain_validation_key( "bootstrap", &base_tip, @@ -280,19 +279,22 @@ pub(super) async fn process_envelope( return Ok(false); } let adjusted_time_ms = super::network_adjusted_time_ms(network).await; - let result = match validate_chain_bootstrap(bootstrap, adjusted_time_ms).await { - Ok(ledger) => { - let mut node = network.inner.node.lock().await; - if network.sync_generation_is_current(sync_generation) - && node.ledger().tip_hash() == base_tip - { - node.import_verified_ledger(ledger).map(|_| ()) - } else { - Ok(()) + let result = + match validate_chain_bootstrap(&expected_profile_id, bootstrap, adjusted_time_ms) + .await + { + Ok(ledger) => { + let mut node = network.inner.node.lock().await; + if network.sync_generation_is_current(sync_generation) + && node.ledger().tip_hash() == base_tip + { + node.import_verified_ledger(ledger).map(|_| ()) + } else { + Ok(()) + } } - } - Err(error) => Err(error), - }; + Err(error) => Err(error), + }; record_rejected_chain_payload( network, &network.inner.metrics.rejected_snapshots, diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs @@ -6,8 +6,8 @@ use std::{ use crate::{ app::{ - GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, PeerBook, PeerDirection, - ProtocolHello, + GossipEnvelope, MAINNET_CANDIDATE_NETWORK_ID, NETWORK_ID, NodeCore, PROTOCOL_VERSION, + PeerBook, PeerDirection, ProtocolHello, }, domain::{Ledger, Wallet, run_vdf}, }; @@ -1389,7 +1389,7 @@ async fn inbound_verification_only_session_closes_after_response() { } #[tokio::test] -async fn setup_placeholder_accepts_remote_genesis_and_adopts_bootstrap() { +async fn setup_placeholder_rejects_bootstrap_with_unpinned_candidate_genesis() { let local_wallet = Wallet::from_seed("setup-placeholder-local"); let local_ledger = Ledger::new(BTreeMap::new(), 1); let local_node = Arc::new(tokio::sync::Mutex::new(NodeCore::from_ledger( @@ -1454,22 +1454,17 @@ async fn setup_placeholder_accepts_remote_genesis_and_adopts_bootstrap() { assert_eq!(peer.misbehavior_score, 0); assert!(!peer.is_banned_at(crate::app::now_ms())); - let adopted = super::validate_chain_bootstrap(remote_bootstrap, crate::app::now_ms()) - .await - .unwrap(); - assert_eq!(adopted.genesis_hash(), remote_genesis); - assert!( - network - .inner - .node - .lock() - .await - .import_verified_ledger(adopted) - .unwrap() - ); + let error = super::validate_chain_bootstrap( + MAINNET_CANDIDATE_NETWORK_ID, + remote_bootstrap, + crate::app::now_ms(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("does not match pinned genesis")); assert_eq!( network.inner.node.lock().await.ledger().genesis_hash(), - remote_genesis + local_ledger.genesis_hash() ); } diff --git a/src/app.rs b/src/app.rs @@ -42,6 +42,8 @@ pub const DEFAULT_BURN_PER_BLOCK: Amount = 0; pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; pub const PROTOCOL_VERSION: u32 = 2; pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate"; +pub const MAINNET_CANDIDATE_GENESIS_HASH: &str = + "3d677cd7ced1c04d3a276cbee7ea38076e34ac65f18a2c9b8286a4872d986a9a"; pub const MAINNET_NETWORK_ID: &str = "iuna-mainnet-v1"; pub const NETWORK_ID: &str = MAINNET_CANDIDATE_NETWORK_ID; pub const BLOCK_REQUEST_LIMIT: usize = 128; @@ -60,8 +62,9 @@ static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); #[cfg(test)] mod tests { use super::{ - BLOCK_REQUEST_LIMIT, DEFAULT_VDF_ROUNDS, MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, - NETWORK_ID, PROTOCOL_VERSION, TRANSACTION_BATCH_LIMIT, + BLOCK_REQUEST_LIMIT, DEFAULT_VDF_ROUNDS, MAINNET_CANDIDATE_GENESIS_HASH, + MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, NETWORK_ID, PROTOCOL_VERSION, + TRANSACTION_BATCH_LIMIT, validate_network_genesis, }; #[test] @@ -69,12 +72,33 @@ mod tests { assert_eq!(DEFAULT_VDF_ROUNDS, 67_000_000); assert_eq!(PROTOCOL_VERSION, 2); assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate"); + assert_eq!(MAINNET_CANDIDATE_GENESIS_HASH.len(), 64); assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1"); assert_ne!(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID); assert_eq!(NETWORK_ID, MAINNET_CANDIDATE_NETWORK_ID); assert_eq!(BLOCK_REQUEST_LIMIT, 128); assert_eq!(TRANSACTION_BATCH_LIMIT, 128); } + + #[test] + fn candidate_genesis_is_pinned_while_local_profiles_remain_unpinned() { + assert!( + validate_network_genesis(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_CANDIDATE_GENESIS_HASH) + .is_ok() + ); + assert!(validate_network_genesis(MAINNET_CANDIDATE_NETWORK_ID, &"0".repeat(64)).is_err()); + assert!(validate_network_genesis("iuna-local-testnet-v1", &"0".repeat(64)).is_ok()); + } +} + +pub fn validate_network_genesis(profile_id: &str, genesis_hash: &str) -> Result<()> { + if profile_id == MAINNET_CANDIDATE_NETWORK_ID && genesis_hash != MAINNET_CANDIDATE_GENESIS_HASH + { + anyhow::bail!( + "mainnet-candidate genesis {genesis_hash} does not match pinned genesis {MAINNET_CANDIDATE_GENESIS_HASH}" + ); + } + Ok(()) } pub fn set_debug_logging(enabled: bool) { diff --git a/src/main.rs b/src/main.rs @@ -17,7 +17,7 @@ use iuna::{ }, app::{ NodeCore, PeerBook, SharedNode, SharedPeerBook, StratumStatus, debug_logging_enabled, - now_ms, set_debug_logging, + now_ms, set_debug_logging, validate_network_genesis, }, domain::{ Amount, ChainSnapshot, GenesisBurn, LaunchProfile, Ledger, MAX_VDF_ROUNDS, MICRO_IUNA, @@ -172,6 +172,7 @@ async fn main() -> Result<()> { &chain_store, advertised_p2p_addr, startup_local_testnet, + true, ) .await?; let migration_from = initialized_ledger.migration_from.clone(); @@ -581,6 +582,7 @@ async fn initialize_ledger( chain_store: &SqliteChainStore, advertised_p2p_addr: SocketAddr, local_testnet: bool, + enforce_pinned_genesis: bool, ) -> Result<InitializedLedger> { if let Some(loaded) = chain_store.load_with_verification_status()? { let snapshot = loaded.snapshot; @@ -601,6 +603,13 @@ async fn initialize_ledger( migration_from: Some(snapshot.launch_profile.profile_id), }); } + if enforce_pinned_genesis { + let stored_genesis = snapshot + .blocks + .first() + .context("persisted chain is missing its genesis block")?; + validate_network_genesis(&snapshot.launch_profile.profile_id, &stored_genesis.hash)?; + } let height = snapshot_height(&snapshot); match loaded.revalidation_from_height { Some(from_height) => println!( @@ -684,6 +693,11 @@ fn setup_ledger(local_testnet: bool) -> Ledger { } fn start_genesis_ledger(wallet_address: &str, local_testnet: bool) -> Result<Ledger> { + if !local_testnet { + bail!( + "mainnet-candidate genesis is pinned; use --join instead of creating a new candidate chain" + ); + } let vdf_rounds = measure_initial_vdf_rounds(); let mut genesis = BTreeMap::new(); genesis.insert(wallet_address.to_string(), GENESIS_BOOTSTRAP_BALANCE); @@ -692,7 +706,7 @@ fn start_genesis_ledger(wallet_address: &str, local_testnet: bool) -> Result<Led } else { LaunchProfile::default() }; - Ledger::new_with_genesis_burns_and_profile( + let ledger = Ledger::new_with_genesis_burns_and_profile( genesis, vec![GenesisBurn::new( wallet_address, @@ -700,7 +714,9 @@ fn start_genesis_ledger(wallet_address: &str, local_testnet: bool) -> Result<Led )], vdf_rounds, launch_profile, - ) + )?; + validate_network_genesis(&ledger.launch_profile().profile_id, ledger.genesis_hash())?; + Ok(ledger) } fn measure_initial_vdf_rounds() -> u64 { diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -26,7 +26,7 @@ use super::{ parse_startup_bool_env_value, parse_startup_pow_mining_workers_env_value, persist_chain_snapshot, project_ui_data_store, run_chain_persistence_with_interval, setup_ledger, should_defer_sync_checkpoint, should_log_automatic_finalization_skip, - validate_wallet_for_mode, + start_genesis_ledger, validate_wallet_for_mode, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -691,6 +691,15 @@ fn genesis_mode_is_explicit() { } #[test] +fn candidate_genesis_mode_cannot_create_a_second_network() { + let wallet = Wallet::from_seed("second-candidate-genesis"); + let error = start_genesis_ledger(wallet.address(), false).unwrap_err(); + + assert!(error.to_string().contains("genesis is pinned")); + assert!(error.to_string().contains("use --join")); +} + +#[test] fn join_mode_does_not_start_new_chain() { let opts = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); assert_eq!(opts.chain_mode, ChainMode::Join); @@ -1075,9 +1084,16 @@ async fn genesis_refuses_to_start_when_chain_database_exists() { .unwrap() .unwrap(); - let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr, false) - .await - .unwrap_err(); + let error = initialize_ledger( + &opts, + fresh_wallet.address(), + &store, + opts.p2p_addr, + false, + false, + ) + .await + .unwrap_err(); assert!( error.to_string().contains("already contains a blockchain"), @@ -1098,9 +1114,16 @@ async fn startup_resumes_persisted_chain_without_genesis_flag() { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr, false) - .await - .unwrap(); + let resumed = initialize_ledger( + &opts, + fresh_wallet.address(), + &store, + opts.p2p_addr, + false, + false, + ) + .await + .unwrap(); assert_eq!(resumed.status().height, 1); assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); @@ -1109,6 +1132,25 @@ async fn startup_resumes_persisted_chain_without_genesis_flag() { } #[tokio::test] +async fn startup_rejects_unpinned_candidate_genesis() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let wallet = Wallet::from_seed("unpinned-candidate-genesis"); + let persisted = ledger_with_one_mined_block(&wallet); + store.save(&persisted.snapshot()).unwrap(); + let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, true) + .await + .unwrap_err(); + + assert!(error.to_string().contains("does not match pinned genesis")); +} + +#[tokio::test] async fn startup_rebuilds_state_from_a_locally_trusted_chain() { let dir = tempdir().unwrap(); let chain_path = dir.path().join("chain.sqlite3"); @@ -1120,7 +1162,7 @@ async fn startup_rebuilds_state_from_a_locally_trusted_chain() { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false) + let resumed = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, false) .await .unwrap(); @@ -1162,6 +1204,7 @@ async fn local_testnet_requests_reset_for_persisted_normal_launch_profile() { &store, opts.p2p_addr, true, + false, ) .await .unwrap(); @@ -1186,9 +1229,10 @@ async fn startup_requests_reset_for_a_legacy_network_profile() { .unwrap() .unwrap(); - let initialized = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false) - .await - .unwrap(); + let initialized = + initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, false) + .await + .unwrap(); assert_eq!( initialized.migration_from.as_deref(), @@ -1222,9 +1266,16 @@ async fn candidate_promotion_reuses_chain_data_and_can_continue_mining() { .unwrap() .unwrap(); - let mut promoted = initialize_ledger(&opts, wallets[0].address(), &store, opts.p2p_addr, false) - .await - .unwrap(); + let mut promoted = initialize_ledger( + &opts, + wallets[0].address(), + &store, + opts.p2p_addr, + false, + false, + ) + .await + .unwrap(); assert_eq!(promoted.genesis_hash(), candidate_genesis); assert_eq!(promoted.tip_hash(), candidate_tip); @@ -1298,9 +1349,16 @@ async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr, false) - .await - .unwrap(); + let resumed = initialize_ledger( + &opts, + fresh_wallet.address(), + &store, + opts.p2p_addr, + false, + false, + ) + .await + .unwrap(); assert_eq!(resumed.status().height, 1); assert_eq!( @@ -1327,7 +1385,7 @@ async fn persisted_chain_satisfies_join_mode_without_contacting_peer() { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr, false) + let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr, false, false) .await .unwrap(); @@ -1355,7 +1413,7 @@ VALUES (1, 4, 'bad-tip', x'00010203', 0) .unwrap() .unwrap(); - let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false) + let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, false) .await .unwrap_err();