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