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:
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();