iuna

iuna

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

commit 0d5f3c0f6863a004beb0c15dc3ca1db869e7de6f
parent 3fff0452022fc2802fe7befd12b2741fb2eeec9b
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat, 22 Aug 2026 00:06:16 +0200

Fix local burn committee quorum handling

Diffstat:
MREADME.md | 8++++++++
Mdocker-compose.yml | 1+
Msrc/adapters/chain_store/compact.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/adapters/http/ui.rs | 46+++++++++++++++-------------------------------
Msrc/app/automatic_mining.rs | 33+++++++++++++++++++++++++++++++++
Msrc/app/status.rs | 1+
Msrc/app/types.rs | 1+
Msrc/domain/adversarial_tests.rs | 35+++++++++++++++++++++++++++++++++++
Msrc/domain/ledger_chain.rs | 32++++++++++++++++++++++++++++----
Msrc/domain/ledger_queries.rs | 2+-
Msrc/domain/profile.rs | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/main.rs | 32++++++++++++++++++++++++++------
Msrc/main_tests.rs | 37+++++++++++++++++++++++++++++++------
13 files changed, 345 insertions(+), 73 deletions(-)

diff --git a/README.md b/README.md @@ -156,6 +156,14 @@ settings: - `IUNA_AUTOMATIC_BURN_ENABLED=true|false` - `IUNA_POW_MINING_ENABLED=true|false` +The compose bootstrap also selects the isolated `iuna-local-testnet-v1` launch +profile. Its PoW burn-committee lineages are eligible immediately, so node2 can +provide a real second committee attestation as soon as its first mine action is +confirmed. The normal launch profile retains the 20-block lineage maturity. +Existing compose volumes created with the normal profile must be reset once +with `docker compose down -v`, because consensus launch profiles cannot be +changed in place. + Stop the network while keeping chain data: ```sh diff --git a/docker-compose.yml b/docker-compose.yml @@ -21,6 +21,7 @@ services: environment: IUNA_WALLET_PASSWORD: ${IUNA_BOOTSTRAP_WALLET_PASSWORD:-local-testnet-bootstrap-password} IUNA_SETUP_COMPLETE: "true" + IUNA_LOCAL_TESTNET: "true" entrypoint: ["/bin/sh", "-c"] command: - if [ -s /data/chain.sqlite3 ]; then exec iuna --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.10:9444 --debug; else exec iuna --genesis --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.10:9444 --debug; fi diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -6,7 +6,8 @@ use crate::domain::{ }; const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT"; -const COMPACT_SNAPSHOT_VERSION: u8 = 4; +const LEGACY_COMPACT_SNAPSHOT_VERSION: u8 = 4; +const COMPACT_SNAPSHOT_VERSION: u8 = 5; const MAX_COMPACT_GENESIS_ALLOCATIONS: usize = 100_000; const MAX_COMPACT_SNAPSHOT_BLOCKS: usize = 10_000; const MAX_COMPACT_VEC_ITEMS: usize = 10_000; @@ -15,14 +16,21 @@ const MAX_COMPACT_BYTE_FIELD: usize = 8 * 1024 * 1024; pub(super) fn encode_compact_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<u8>> { let mut writer = CompactWriter::default(); writer.bytes(COMPACT_SNAPSHOT_MAGIC); - writer.u8(COMPACT_SNAPSHOT_VERSION); + let version = if snapshot.launch_profile.burn_lineage_maturity_heights + == crate::domain::BURN_LINEAGE_MATURITY_HEIGHTS + { + LEGACY_COMPACT_SNAPSHOT_VERSION + } else { + COMPACT_SNAPSHOT_VERSION + }; + writer.u8(version); writer.varint(snapshot.genesis_allocations.len() as u64); for (address, amount) in &snapshot.genesis_allocations { writer.hex(address)?; writer.varint(*amount); } writer.varint(snapshot.vdf_rounds); - encode_launch_profile(&mut writer, &snapshot.launch_profile); + encode_launch_profile(&mut writer, &snapshot.launch_profile, version); writer.varint(snapshot.blocks.len() as u64); let mut expected_prev_hash = "0".repeat(64); for (height, block) in snapshot.blocks.iter().enumerate() { @@ -49,7 +57,10 @@ pub(super) fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { let mut reader = CompactReader::new(bytes); reader.magic(COMPACT_SNAPSHOT_MAGIC)?; let version = reader.u8()?; - if version != COMPACT_SNAPSHOT_VERSION { + if !matches!( + version, + LEGACY_COMPACT_SNAPSHOT_VERSION | COMPACT_SNAPSHOT_VERSION + ) { bail!("unsupported compact chain snapshot version {version}"); } let genesis_count = @@ -61,7 +72,7 @@ pub(super) fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { genesis_allocations.insert(address, amount); } let vdf_rounds = reader.varint()?; - let launch_profile = decode_launch_profile(&mut reader)?; + let launch_profile = decode_launch_profile(&mut reader, version)?; let block_count = reader.bounded_usize("block count", MAX_COMPACT_SNAPSHOT_BLOCKS)?; let mut blocks = Vec::with_capacity(block_count); let mut prev_hash = "0".repeat(64); @@ -79,22 +90,30 @@ pub(super) fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { }) } -fn encode_launch_profile(writer: &mut CompactWriter, profile: &LaunchProfile) { +fn encode_launch_profile(writer: &mut CompactWriter, profile: &LaunchProfile, version: u8) { writer.string(&profile.profile_id); writer.varint(profile.ticket_maturity_delay_heights); writer.varint(profile.ticket_expiry_window_heights); writer.varint(u64::from(profile.mine_difficulty_bits)); + if version >= COMPACT_SNAPSHOT_VERSION { + writer.varint(profile.burn_lineage_maturity_heights); + } writer.varint(profile.max_pending_transactions as u64); writer.varint(profile.max_block_transactions as u64); writer.varint(profile.max_block_bytes as u64); } -fn decode_launch_profile(reader: &mut CompactReader<'_>) -> Result<LaunchProfile> { +fn decode_launch_profile(reader: &mut CompactReader<'_>, version: u8) -> Result<LaunchProfile> { Ok(LaunchProfile { profile_id: reader.string()?, ticket_maturity_delay_heights: reader.varint()?, ticket_expiry_window_heights: reader.varint()?, mine_difficulty_bits: reader.u32()?, + burn_lineage_maturity_heights: if version >= COMPACT_SNAPSHOT_VERSION { + reader.varint()? + } else { + crate::domain::BURN_LINEAGE_MATURITY_HEIGHTS + }, max_pending_transactions: reader.usize()?, max_block_transactions: reader.usize()?, max_block_bytes: reader.usize()?, @@ -576,23 +595,76 @@ fn hex_encode(bytes: impl AsRef<[u8]>) -> String { #[cfg(test)] mod tests { - use std::panic; + use std::{collections::BTreeMap, panic}; - use crate::domain::LaunchProfile; + use crate::domain::{LaunchProfile, Ledger, MICRO_IUNA, Wallet}; use super::{ - COMPACT_SNAPSHOT_MAGIC, COMPACT_SNAPSHOT_VERSION, CompactWriter, MAX_COMPACT_BYTE_FIELD, - MAX_COMPACT_GENESIS_ALLOCATIONS, MAX_COMPACT_SNAPSHOT_BLOCKS, MAX_COMPACT_VEC_ITEMS, - decode_compact_snapshot, encode_launch_profile, + COMPACT_SNAPSHOT_MAGIC, COMPACT_SNAPSHOT_VERSION, CompactReader, CompactWriter, + LEGACY_COMPACT_SNAPSHOT_VERSION, MAX_COMPACT_BYTE_FIELD, MAX_COMPACT_GENESIS_ALLOCATIONS, + MAX_COMPACT_SNAPSHOT_BLOCKS, MAX_COMPACT_VEC_ITEMS, decode_compact_snapshot, + decode_launch_profile, encode_compact_snapshot, encode_launch_profile, }; + #[test] + fn default_profile_keeps_v4_wire_format_while_local_profile_uses_v5() { + let wallet = Wallet::from_seed("compact-profile-wire-version"); + let allocations = BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]); + let default_snapshot = Ledger::new(allocations.clone(), 1).snapshot(); + let default_bytes = encode_compact_snapshot(&default_snapshot).unwrap(); + assert_eq!( + default_bytes[COMPACT_SNAPSHOT_MAGIC.len()], + LEGACY_COMPACT_SNAPSHOT_VERSION + ); + assert_eq!( + decode_compact_snapshot(&default_bytes).unwrap(), + default_snapshot + ); + + let local_ledger = Ledger::new_with_genesis_burns_and_profile( + allocations, + Vec::new(), + 1, + LaunchProfile::local_testnet(), + ) + .unwrap(); + let local_snapshot = local_ledger.snapshot(); + let local_bytes = encode_compact_snapshot(&local_snapshot).unwrap(); + assert_eq!( + local_bytes[COMPACT_SNAPSHOT_MAGIC.len()], + COMPACT_SNAPSHOT_VERSION + ); + assert_eq!( + decode_compact_snapshot(&local_bytes).unwrap(), + local_snapshot + ); + } + + #[test] + fn compact_launch_profile_roundtrips_local_lineage_maturity() { + let expected = LaunchProfile::local_testnet(); + let mut writer = CompactWriter::default(); + encode_launch_profile(&mut writer, &expected, COMPACT_SNAPSHOT_VERSION); + let bytes = writer.into_inner(); + let mut reader = CompactReader::new(&bytes); + + let decoded = decode_launch_profile(&mut reader, COMPACT_SNAPSHOT_VERSION).unwrap(); + + reader.finish().unwrap(); + assert_eq!(decoded, expected); + } + fn snapshot_prefix(block_count: u64) -> Vec<u8> { let mut writer = CompactWriter::default(); writer.bytes(COMPACT_SNAPSHOT_MAGIC); writer.u8(COMPACT_SNAPSHOT_VERSION); writer.varint(0); writer.varint(1); - encode_launch_profile(&mut writer, &LaunchProfile::default()); + encode_launch_profile( + &mut writer, + &LaunchProfile::default(), + COMPACT_SNAPSHOT_VERSION, + ); writer.varint(block_count); writer.into_inner() } diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -1,8 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use crate::domain::{ - Amount, BURN_COMMITTEE_SIZE, Block, BurnLeaderRank, ChainSnapshot, FinalizerMode, MINE_REWARD, - OutPoint, Transaction, TxInput, TxOutput, + Amount, Block, BurnLeaderRank, ChainSnapshot, FinalizerMode, MINE_REWARD, OutPoint, + Transaction, TxInput, TxOutput, }; use crate::adapters::ui_index::build_ui_chain_index; @@ -175,7 +175,7 @@ pub(super) fn ui_block( .get(&block.hash) .cloned() .unwrap_or_default(); - let (burn_bundles_included, burn_bundles_required) = burn_bundle_wallet_quorum(&block, &ranks); + let (burn_bundles_included, burn_bundles_required) = burn_bundle_wallet_quorum(&block); let public_fees = block .transactions .iter() @@ -243,33 +243,17 @@ pub(super) fn ui_block( } } -fn burn_bundle_wallet_quorum(block: &Block, ranks: &[BurnLeaderRank]) -> (usize, usize) { +fn burn_bundle_wallet_quorum(block: &Block) -> (usize, usize) { if block.finalizer_mode != FinalizerMode::Ticket { return (0, 0); } - let owner_count = ranks - .iter() - .filter(|rank| rank.rank >= block.finalizer_rank) - .map(|rank| rank.owner.as_str()) - .chain(std::iter::once(block.miner.as_str())) - .collect::<BTreeSet<_>>() - .len(); - let rank_committee_cap = BURN_COMMITTEE_SIZE - .saturating_sub(block.finalizer_rank as usize) - .max(1); - let committee_size = owner_count.min(rank_committee_cap); - let included = block - .burn_bundle_section - .signatures - .iter() - .map(|signature| signature.member.as_str()) - .chain(std::iter::once(block.miner.as_str())) - .collect::<BTreeSet<_>>() - .len() - .min(committee_size); - - (included, committee_size) + // Consensus requires every available explicit committee signature for rank 0 + // and rank 1. Rank 2 and later have no additional committee slots. Therefore + // an accepted ticket block records its actual quorum without consulting the + // unrelated burn-ticket rank owners. + let committee_size = block.burn_bundle_section.signatures.len().saturating_add(1); + (committee_size, committee_size) } fn block_lost_iuna(transactions: &[Transaction], reward: Amount) -> Amount { @@ -656,8 +640,8 @@ mod tests { let ui = ui_block(block, &BTreeMap::new(), &BTreeMap::new()); - assert_eq!(ui.burn_bundle_quorum.burn_bundles_included, 1); - assert_eq!(ui.burn_bundle_quorum.committee_size, 1); + assert_eq!(ui.burn_bundle_quorum.burn_bundles_included, 2); + assert_eq!(ui.burn_bundle_quorum.committee_size, 2); assert_eq!(ui.lost_iuna, 1); assert_eq!(ui.burn_bundles.len(), 1); assert_eq!(ui.burn_bundles[0].slot, 1); @@ -701,7 +685,7 @@ mod tests { } #[test] - fn ui_block_burn_bundle_quorum_counts_unique_ticket_wallets() { + fn ui_block_burn_bundle_quorum_uses_included_lineage_attestations() { let block = Block { height: 1, prev_hash: "parent".to_string(), @@ -777,7 +761,7 @@ mod tests { } #[test] - fn ui_block_burn_bundle_quorum_uses_fallback_rank_window() { + fn ui_block_burn_bundle_quorum_ignores_unrelated_ticket_rank_window() { let block = Block { height: 1, prev_hash: "parent".to_string(), diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -166,6 +166,11 @@ impl NodeCore { } if !self.automatic_mining_enabled { + if let Err(error) = self.publish_burn_bundle_for_next_block() { + plan.skipped_reason = Some(format!("burn committee signing failed: {error:#}")); + self.last_auto_finalization_status = plan.skipped_reason.clone(); + return plan; + } plan.skipped_reason = Some("automatic mining is off".to_string()); self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; @@ -934,6 +939,34 @@ mod tests { } #[test] + fn automatic_finalization_disabled_node_still_publishes_committee_bundle() { + let wallet = Wallet::from_seed("disabled-finalizer-committee-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, false, 0, 1); + + let plan = node.prepare_automatic_finalization(1); + + assert_eq!( + plan.skipped_reason.as_deref(), + Some("automatic mining is off") + ); + assert!(node.drain_outbox().into_iter().any(|envelope| { + matches!( + envelope, + GossipEnvelope::BurnBundle(bundle) if bundle.member == wallet.address() + ) + })); + } + + #[test] fn publish_burn_bundle_rebroadcasts_existing_local_bundle() { let wallet = Wallet::from_seed("rebroadcast-existing-bundle-wallet"); let mut allocations = BTreeMap::new(); diff --git a/src/app/status.rs b/src/app/status.rs @@ -31,6 +31,7 @@ impl NodeCore { ticket_maturity_delay_heights: launch_profile.ticket_maturity_delay_heights, ticket_expiry_window_heights: launch_profile.ticket_expiry_window_heights, mine_difficulty_bits: launch_profile.mine_difficulty_bits, + burn_lineage_maturity_heights: launch_profile.burn_lineage_maturity_heights, }, mining: MiningStatus { automatic: self.automatic_mining_enabled, diff --git a/src/app/types.rs b/src/app/types.rs @@ -127,6 +127,7 @@ pub struct LaunchProfileStatus { pub ticket_maturity_delay_heights: u64, pub ticket_expiry_window_heights: u64, pub mine_difficulty_bits: u32, + pub burn_lineage_maturity_heights: u64, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -2938,6 +2938,41 @@ fn non_mature_lineage_cannot_join_committee() { } #[test] +fn local_testnet_lineage_is_immediately_required_by_rank_zero_quorum() { + let mut harness = harness_for_percent(141, 25); + harness.ledger.launch_profile.burn_lineage_maturity_heights = 0; + let committee_wallet = harness.attacker.lineage_wallets[0].clone(); + let mine = harness + .ledger + .build_mine(committee_wallet.address()) + .unwrap(); + harness.ledger.submit_transaction(mine).unwrap(); + harness.mine_ticket_block(0); + + let committee = harness.ledger.burn_committee_for_next_block(); + assert!( + committee + .iter() + .any(|member| member.slot > 0 && member.owner == committee_wallet.address()), + "local-testnet lineage did not enter the next committee: {committee:?}" + ); + + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let missing_bundle_block = harness.finish_ticket_block_from_pending(0, Vec::new()); + let error = harness + .ledger + .apply_block_at( + missing_bundle_block, + NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS), + ) + .unwrap_err(); + + assert!(error.to_string().contains("too few burn bundle signatures")); +} + +#[test] fn changing_attestation_set_after_vdf_changes_seed_and_invalidates_block() { let mut harness = harness_for_percent(15, 25); let leader = harness.next_rank(0); diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -13,8 +13,13 @@ use super::{ impl Ledger { pub fn new(genesis_allocations: BTreeMap<String, Amount>, vdf_rounds: u64) -> Self { - Self::new_with_genesis_transactions(genesis_allocations, Vec::new(), vdf_rounds) - .expect("empty genesis transactions are valid") + Self::new_with_genesis_transactions( + genesis_allocations, + Vec::new(), + vdf_rounds, + LaunchProfile::default(), + ) + .expect("empty genesis transactions are valid") } pub fn new_with_genesis_burns( @@ -22,6 +27,20 @@ impl Ledger { genesis_burns: Vec<GenesisBurn>, vdf_rounds: u64, ) -> Result<Self> { + Self::new_with_genesis_burns_and_profile( + genesis_allocations, + genesis_burns, + vdf_rounds, + LaunchProfile::default(), + ) + } + + pub fn new_with_genesis_burns_and_profile( + genesis_allocations: BTreeMap<String, Amount>, + genesis_burns: Vec<GenesisBurn>, + vdf_rounds: u64, + launch_profile: LaunchProfile, + ) -> Result<Self> { let transactions = genesis_burns .into_iter() .map(|burn| { @@ -32,16 +51,21 @@ impl Ledger { Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation) }) .collect::<Result<Vec<_>>>()?; - Self::new_with_genesis_transactions(genesis_allocations, transactions, vdf_rounds) + Self::new_with_genesis_transactions( + genesis_allocations, + transactions, + vdf_rounds, + launch_profile, + ) } fn new_with_genesis_transactions( genesis_allocations: BTreeMap<String, Amount>, genesis_transactions: Vec<Transaction>, vdf_rounds: u64, + launch_profile: LaunchProfile, ) -> Result<Self> { validate_genesis_allocations(&genesis_allocations)?; - let launch_profile = LaunchProfile::default(); let genesis = build_genesis_block(&genesis_allocations, genesis_transactions); let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?; diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -320,7 +320,7 @@ impl Ledger { **value > 0 && root .height - .saturating_add(super::BURN_LINEAGE_MATURITY_HEIGHTS) + .saturating_add(self.launch_profile.burn_lineage_maturity_heights) <= parent_height && !skipped_owners .iter() diff --git a/src/domain/profile.rs b/src/domain/profile.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; use super::{ - DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, MAX_BLOCK_BYTES, - MAX_BLOCK_TRANSACTIONS, MAX_PENDING_TRANSACTIONS, MINE_DIFFICULTY_BITS, hex_hash, + BURN_LINEAGE_MATURITY_HEIGHTS, DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, + MAX_BLOCK_BYTES, MAX_BLOCK_TRANSACTIONS, MAX_PENDING_TRANSACTIONS, MINE_DIFFICULTY_BITS, + hex_hash, }; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -13,6 +14,8 @@ pub struct LaunchProfile { pub ticket_expiry_window_heights: u64, #[serde(default = "default_mine_difficulty_bits")] pub mine_difficulty_bits: u32, + #[serde(default = "default_burn_lineage_maturity_heights")] + pub burn_lineage_maturity_heights: u64, pub max_pending_transactions: usize, pub max_block_transactions: usize, #[serde(default = "default_max_block_bytes")] @@ -26,6 +29,7 @@ impl Default for LaunchProfile { ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, mine_difficulty_bits: MINE_DIFFICULTY_BITS, + burn_lineage_maturity_heights: BURN_LINEAGE_MATURITY_HEIGHTS, max_pending_transactions: MAX_PENDING_TRANSACTIONS, max_block_transactions: MAX_BLOCK_TRANSACTIONS, max_block_bytes: MAX_BLOCK_BYTES, @@ -45,18 +49,48 @@ fn default_mine_difficulty_bits() -> u32 { MINE_DIFFICULTY_BITS } +fn default_burn_lineage_maturity_heights() -> u64 { + BURN_LINEAGE_MATURITY_HEIGHTS +} + impl LaunchProfile { + pub fn local_testnet() -> Self { + Self { + profile_id: "iuna-local-testnet-v1".to_string(), + burn_lineage_maturity_heights: 0, + ..Self::default() + } + } + pub fn hash(&self) -> String { - hex_hash(format!( - "iuna-launch-profile:{}:{}:{}:{}:{}:{}:{}", - self.profile_id, - self.ticket_maturity_delay_heights, - self.ticket_expiry_window_heights, - self.mine_difficulty_bits, - self.max_pending_transactions, - self.max_block_transactions, - self.max_block_bytes - )) + let canonical = if self.burn_lineage_maturity_heights == BURN_LINEAGE_MATURITY_HEIGHTS { + // Preserve the existing mainnet-candidate profile hash. The maturity + // used to be a frozen protocol constant, so including its unchanged + // default would needlessly split the existing network. + format!( + "iuna-launch-profile:{}:{}:{}:{}:{}:{}:{}", + self.profile_id, + self.ticket_maturity_delay_heights, + self.ticket_expiry_window_heights, + self.mine_difficulty_bits, + self.max_pending_transactions, + self.max_block_transactions, + self.max_block_bytes + ) + } else { + format!( + "iuna-launch-profile-v2:{}:{}:{}:{}:{}:{}:{}:{}", + self.profile_id, + self.ticket_maturity_delay_heights, + self.ticket_expiry_window_heights, + self.mine_difficulty_bits, + self.burn_lineage_maturity_heights, + self.max_pending_transactions, + self.max_block_transactions, + self.max_block_bytes + ) + }; + hex_hash(canonical) } } @@ -97,6 +131,10 @@ mod tests { DEFAULT_TICKET_EXPIRY_WINDOW ); assert_eq!(profile.mine_difficulty_bits, MINE_DIFFICULTY_BITS); + assert_eq!( + profile.burn_lineage_maturity_heights, + crate::domain::BURN_LINEAGE_MATURITY_HEIGHTS + ); assert_eq!(profile.max_pending_transactions, MAX_PENDING_TRANSACTIONS); assert_eq!(profile.max_block_transactions, MAX_BLOCK_TRANSACTIONS); assert_eq!(profile.max_block_bytes, MAX_BLOCK_BYTES); @@ -107,6 +145,36 @@ mod tests { } #[test] + fn local_testnet_profile_has_immediate_burn_lineage_eligibility() { + let profile = LaunchProfile::local_testnet(); + + assert_eq!(profile.profile_id, "iuna-local-testnet-v1"); + assert_eq!(profile.burn_lineage_maturity_heights, 0); + assert_ne!(profile.hash(), LaunchProfile::default().hash()); + } + + #[test] + fn legacy_json_profile_defaults_burn_lineage_maturity() { + let profile: LaunchProfile = serde_json::from_str( + r#"{ + "profile_id":"legacy", + "ticket_maturity_delay_heights":3, + "ticket_expiry_window_heights":3, + "mine_difficulty_bits":12, + "max_pending_transactions":10000, + "max_block_transactions":1000, + "max_block_bytes":1000000 + }"#, + ) + .unwrap(); + + assert_eq!( + profile.burn_lineage_maturity_heights, + crate::domain::BURN_LINEAGE_MATURITY_HEIGHTS + ); + } + + #[test] fn genesis_burn_constructor_preserves_fields() { let burn = GenesisBurn::new("alice", 42); diff --git a/src/main.rs b/src/main.rs @@ -17,7 +17,7 @@ use iuna::{ now_ms, set_debug_logging, }, domain::{ - Amount, ChainSnapshot, GenesisBurn, Ledger, MAX_VDF_ROUNDS, MICRO_IUNA, + Amount, ChainSnapshot, GenesisBurn, LaunchProfile, Ledger, MAX_VDF_ROUNDS, MICRO_IUNA, VDF_TARGET_BLOCK_MS, VdfProgress, VdfProgressPhase, run_vdf, run_vdf_with_progress, }, }; @@ -42,6 +42,7 @@ const VDF_MEASUREMENT_MIN_ELAPSED: Duration = Duration::from_millis(150); const VDF_PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(10); const AUTOMATIC_BURN_ENABLED_ENV: &str = "IUNA_AUTOMATIC_BURN_ENABLED"; const POW_MINING_ENABLED_ENV: &str = "IUNA_POW_MINING_ENABLED"; +const LOCAL_TESTNET_ENV: &str = "IUNA_LOCAL_TESTNET"; const SETUP_COMPLETE_ENV: &str = "IUNA_SETUP_COMPLETE"; const WALLET_PASSWORD_ENV: &str = "IUNA_WALLET_PASSWORD"; @@ -71,6 +72,7 @@ async fn main() -> Result<()> { let startup_wallet_password = startup_wallet_password_from_env()?; let startup_automatic_burn_enabled = startup_bool_from_env(AUTOMATIC_BURN_ENABLED_ENV)?; let startup_pow_mining_enabled = startup_bool_from_env(POW_MINING_ENABLED_ENV)?; + let startup_local_testnet = startup_bool_from_env(LOCAL_TESTNET_ENV)?.unwrap_or(false); let startup_setup_complete = startup_bool_from_env(SETUP_COMPLETE_ENV)?; let p2p_config_dirty = apply_cli_p2p_config_overrides(&opts, &mut ui_config); let stratum_config_dirty = apply_cli_stratum_config_overrides(&opts, &mut ui_config); @@ -112,8 +114,14 @@ async fn main() -> Result<()> { if ui_config_dirty || auth_config_dirty { config_store::save(&config_path, &ui_config)?; } - let ledger = - initialize_ledger(&opts, &wallet_address, &chain_store, advertised_p2p_addr).await?; + let ledger = initialize_ledger( + &opts, + &wallet_address, + &chain_store, + advertised_p2p_addr, + startup_local_testnet, + ) + .await?; let has_chain = opts.has_chain() || persisted_chain_exists; let initial_burn_per_block = initial_burn_per_block(&opts, &ui_config); let initial_burn_fee = initial_burn_fee(&opts, &ui_config); @@ -427,6 +435,7 @@ async fn initialize_ledger( wallet_address: &str, chain_store: &SqliteChainStore, advertised_p2p_addr: SocketAddr, + local_testnet: bool, ) -> Result<Ledger> { if let Some(snapshot) = chain_store.load()? { if opts.chain_mode == ChainMode::Genesis { @@ -435,6 +444,11 @@ async fn initialize_ledger( chain_store.path().display() ); } + if local_testnet && snapshot.launch_profile != LaunchProfile::local_testnet() { + bail!( + "{LOCAL_TESTNET_ENV}=true requires the iuna-local-testnet-v1 launch profile; reset this local chain before restarting" + ); + } let height = snapshot_height(&snapshot); let ledger = Ledger::from_persisted_snapshot(snapshot).with_context(|| { format!( @@ -450,7 +464,7 @@ async fn initialize_ledger( } else { match opts.chain_mode { ChainMode::Setup => Ok(setup_ledger()), - ChainMode::Genesis => start_genesis_ledger(wallet_address), + ChainMode::Genesis => start_genesis_ledger(wallet_address, local_testnet), ChainMode::Join => join_chain_ledger(&opts.join_peers, advertised_p2p_addr).await, } } @@ -468,17 +482,23 @@ fn setup_ledger() -> Ledger { Ledger::new(BTreeMap::new(), 1) } -fn start_genesis_ledger(wallet_address: &str) -> Result<Ledger> { +fn start_genesis_ledger(wallet_address: &str, local_testnet: bool) -> Result<Ledger> { let vdf_rounds = measure_initial_vdf_rounds(); let mut genesis = BTreeMap::new(); genesis.insert(wallet_address.to_string(), GENESIS_BOOTSTRAP_BALANCE); - Ledger::new_with_genesis_burns( + let launch_profile = if local_testnet { + LaunchProfile::local_testnet() + } else { + LaunchProfile::default() + }; + Ledger::new_with_genesis_burns_and_profile( genesis, vec![GenesisBurn::new( wallet_address, GENESIS_BOOTSTRAP_BURN_AMOUNT, )], vdf_rounds, + launch_profile, ) } diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -756,7 +756,7 @@ async fn genesis_refuses_to_start_when_chain_database_exists() { .unwrap() .unwrap(); - let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) + let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr, false) .await .unwrap_err(); @@ -779,7 +779,7 @@ async fn startup_resumes_persisted_chain_without_genesis_flag() { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) + let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr, false) .await .unwrap(); @@ -790,6 +790,31 @@ async fn startup_resumes_persisted_chain_without_genesis_flag() { } #[tokio::test] +async fn local_testnet_refuses_persisted_chain_with_normal_launch_profile() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let persisted_wallet = Wallet::from_seed("normal-profile-chain-owner"); + let persisted = ledger_with_one_mined_block(&persisted_wallet); + store.save(&persisted.snapshot()).unwrap(); + let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let error = initialize_ledger( + &opts, + persisted_wallet.address(), + &store, + opts.p2p_addr, + true, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("reset this local chain")); +} + +#[tokio::test] async fn candidate_promotion_reuses_chain_data_and_can_continue_mining() { assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate-v1"); assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1"); @@ -810,7 +835,7 @@ 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) + let mut promoted = initialize_ledger(&opts, wallets[0].address(), &store, opts.p2p_addr, false) .await .unwrap(); @@ -886,7 +911,7 @@ 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) + let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr, false) .await .unwrap(); @@ -915,7 +940,7 @@ async fn persisted_chain_satisfies_join_mode_without_contacting_peer() { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr) + let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr, false) .await .unwrap(); @@ -943,7 +968,7 @@ VALUES (1, 4, 'bad-tip', x'00010203', 0) .unwrap() .unwrap(); - let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr) + let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false) .await .unwrap_err();