iuna

iuna

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

commit c4dc4cd718bab729c5c51956797169386e1a0b9b
parent d3eb035f83f0f263aa63dbb23bc81e9da2ffb81a
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 16:29:47 +0200

Freeze mainnet candidate parameters

Diffstat:
MROADMAP.md | 6+++---
Mdocs/protocol.md | 48++++++++++++++++++++++++++++++++++++++++++------
Msrc/app.rs | 19++++++++++++++++++-
Msrc/domain/block.rs | 25+++++++++++++++++++++++--
Msrc/domain/profile.rs | 8++++++--
Msrc/domain/protocol.rs | 37+++++++++++++++++++++++++++++++++++--
Msrc/domain/validation.rs | 42++++++++++++++++++++++++++++++++++++++++--
Msrc/main_tests.rs | 4++--
8 files changed, 169 insertions(+), 20 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md @@ -10,8 +10,8 @@ The current goal is to keep a small real testnet stable while increasing confide ## Mainnet Readiness Checklist -- [ ] Protocol rules are frozen for mainnet candidate. -- [ ] Block, transaction, ticket, VDF, recovery, fork-choice, and peer compatibility rules are documented. +- [x] Protocol rules are frozen for mainnet candidate. +- [x] Block, transaction, ticket, VDF, recovery, fork-choice, and peer compatibility rules are documented. - [ ] Long-running testnet has stayed stable with independent nodes for an agreed window. - [ ] Mainnet-candidate testnet has launched from a fresh genesis using release artifacts. - [ ] New nodes can sync from genesis without manual intervention. @@ -78,7 +78,7 @@ Focus: make failure modes boring and observable. Focus: rehearse mainnet with mainnet-like process, but without mainnet permanence. -- [ ] Freeze protocol parameters for the candidate. +- [x] Freeze protocol parameters for the candidate. - [ ] Create a fresh mainnet-candidate genesis. - [ ] Publish bootnodes and release artifacts. - [ ] Publish checksums for every release artifact. diff --git a/docs/protocol.md b/docs/protocol.md @@ -10,7 +10,43 @@ The goal is to avoid relying on only one scarce resource. Proof-of-work chains t Burns do not remove wealth advantage. More capital can still buy more lottery weight. The difference from stake is that burn power is paid again and again: it expires, does not unbond, and does not accumulate into a permanent stake position. The design converts wealth-bias from a growing asset into a recurring cost. -This is still experimental. The rules below describe the current devnet protocol, not a proven mainnet design. +This is still experimental. The rules below describe the current devnet and mainnet-candidate protocol, not a proven mainnet design. + +## Mainnet-Candidate Frozen Parameters + +The current mainnet-candidate parameter set is intentionally close to Bitcoin where that is useful for operator expectations: + +- P2P network ID: `iuna-mainnet-candidate-v1`; +- protocol version: `1`; +- launch profile ID: `iuna-mainnet-candidate-v1`; +- launch profile hash: `aef51531eaa3a5c5d3ea8a2524ffba029dcb106e4b0a432b57d5ac1f4f8963de`; +- target block time: `10 minutes`; +- maximum serialized block size: `1,000,000` bytes; +- maximum transaction items per block: `1,000`; +- maximum pending transactions per node: `10,000`; +- maximum pending transaction pool bytes per node: `8 MiB`; +- maximum orphan transactions per node: `1,024`; +- ticket maturity delay: `3` blocks; +- ticket expiry window: `3` block heights; +- finality depth: `6` blocks; +- recovery delay: `6` target block times; +- future timestamp drift limit: `2 minutes`; +- VDF retarget window: `20` rank `0` ticket blocks; +- VDF retarget deadband: `10%`; +- VDF maximum retarget step: `2%`; +- PoW mine difficulty start: `12` bits; +- PoW retarget window: `10` blocks; +- PoW target mine actions: `10` per retarget window; +- PoW maximum retarget step: `2` bits; +- PoW minimum difficulty: `10` bits; +- maximum mine actions per anchor: `2`; +- burn committee size: `3` slots; +- maximum signed burn bundle size: `10,000` bytes; +- burn committee lineage maturity: `20` blocks. + +Changing any value in this section requires a conscious mainnet-candidate reset or later hard-fork process. + +Block size is checked from the node's canonical serialized block representation after parsing, so alternate JSON whitespace or key order cannot make a block count smaller. Transaction selection and fee-rate policy use compact economic transaction size: addresses, hashes, signatures, and Stratum headers count as their decoded byte lengths, and numeric fields count as compact base-128 varint widths. That keeps hex text and JSON decimal formatting from making transactions look larger or smaller economically than their protocol data. ## Coins and Transactions @@ -31,7 +67,7 @@ A burn does not immediately select its own block. Instead: 3. The ticket stays eligible for a short expiry window. 4. Its lottery weight is the burned amount. -In the devnet profile, tickets mature after `3` blocks and remain eligible for `3` block heights. +In the mainnet-candidate profile, tickets mature after `3` blocks and remain eligible for `3` block heights. The lottery draw for the next height is deterministic. Nodes rank all eligible burn tickets using the parent block hash, the parent VDF output, the target height, and the ticket amounts. More burned IUNA means more weight, but the winner is still drawn by the protocol. @@ -56,7 +92,7 @@ The VDF is there to make block production sequential and time-based. It uses rep The devnet uses the public RSA-2048 challenge modulus. A production mainnet should use a purpose-specific trusted setup ceremony with destroyed factors, or a class-group VDF that avoids trusted setup. -The target block time is `5 minutes`. The protocol retargets VDF rounds from recent observed block times: +The target block time is `10 minutes`. The protocol retargets VDF rounds from recent observed block times: - It uses a `20` block observation window. - It uses rank `0` ticket blocks for retargeting. @@ -110,7 +146,7 @@ Difficulty targets about one mine action per block: - The retarget window is `10` blocks. - The target is `10` mine actions per window. - Difficulty can move by at most `2` bits per window. -- Difficulty has a minimum of `10` bits in the devnet profile. +- Difficulty has a minimum of `10` bits in the mainnet-candidate profile. - Mine actions expire when their anchor is too old. This keeps issuance separate from finalization. PoW miners compete to create mine actions; burn-ticket finalizers decide blocks. @@ -248,11 +284,11 @@ When a node builds a block, the flow is: 5. Fill remaining block space with valid fee-paying transfers, additional burns, and mine actions ordered by fee rate. Mine actions are limited to `2` actions per anchor. 6. Bind the VDF seed to the three burn-attestation slot hashes, using default hashes for missing slots. Slot `0` uses the synthetic finalizer attestation hash instead of a separate burn-bundle signature. -Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes. +Blocks are bounded by transaction count and serialized byte size. The mainnet-candidate maximum block size is `1,000,000` bytes. ## Fork Choice -Nodes fully validate candidate blocks or snapshots before considering a reorg. A candidate chain must share the same genesis and cannot rewrite history deeper than the finality depth. In the devnet profile, forks whose common ancestor is below `local height - 6` are rejected. +Nodes fully validate candidate blocks or snapshots before considering a reorg. A candidate chain must share the same genesis and cannot rewrite history deeper than the finality depth. In the mainnet-candidate profile, forks whose common ancestor is below `local height - 6` are rejected. Burn inclusion is part of block validity. If a ticket block carries burn-list attestations but omits a burn required by those attestations, nodes reject the block before fork choice. The fork choice rule only compares chains made of valid blocks. diff --git a/src/app.rs b/src/app.rs @@ -39,7 +39,7 @@ pub type SharedPeerBook = Arc<Mutex<PeerBook>>; pub const DEFAULT_BURN_PER_BLOCK: Amount = 0; pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; pub const PROTOCOL_VERSION: u32 = 1; -pub const NETWORK_ID: &str = "iuna-devnet-v3"; +pub const NETWORK_ID: &str = "iuna-mainnet-candidate-v1"; pub const BLOCK_REQUEST_LIMIT: usize = 128; pub const TRANSACTION_BATCH_LIMIT: usize = 128; const IMPORT_REBROADCAST_LIMIT: usize = 128; @@ -54,6 +54,23 @@ const AUTO_BLOCK_ANCHOR_BURN_AMOUNT: Amount = 1; const AUTO_BLOCK_ANCHOR_BURN_FEE: Amount = 1; static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); +#[cfg(test)] +mod tests { + use super::{ + BLOCK_REQUEST_LIMIT, DEFAULT_VDF_ROUNDS, NETWORK_ID, PROTOCOL_VERSION, + TRANSACTION_BATCH_LIMIT, + }; + + #[test] + fn mainnet_candidate_network_parameters_are_frozen() { + assert_eq!(DEFAULT_VDF_ROUNDS, 67_000_000); + assert_eq!(PROTOCOL_VERSION, 1); + assert_eq!(NETWORK_ID, "iuna-mainnet-candidate-v1"); + assert_eq!(BLOCK_REQUEST_LIMIT, 128); + assert_eq!(TRANSACTION_BATCH_LIMIT, 128); + } +} + pub fn set_debug_logging(enabled: bool) { DEBUG_LOGGING.store(enabled, Ordering::Relaxed); } diff --git a/src/domain/block.rs b/src/domain/block.rs @@ -310,8 +310,10 @@ pub struct ChainSnapshot { #[cfg(test)] mod tests { - use super::{Block, FinalizerMode, LeaderProofPayload, canonical_burn_block_items}; - use crate::domain::{BurnBundleSection, Transaction}; + use super::{ + Block, BurnBundleSection, FinalizerMode, LeaderProofPayload, canonical_burn_block_items, + }; + use crate::domain::Transaction; #[test] fn primary_leader_proof_payload_omits_rank_from_canonical_form() { @@ -369,4 +371,23 @@ mod tests { assert_eq!(block.compute_hash(), block.hash); } + + #[test] + fn serialized_block_size_uses_canonical_node_representation() { + let compact_wire_json = format!( + r#"{{"height":1,"prev_hash":"{}","timestamp_ms":1,"miner":"{}","reward":0,"vdf_rounds":1,"vdf_output":"out","leader_proof":null,"transactions":[],"hash":"{}"}}"#, + "0".repeat(64), + "1".repeat(64), + "2".repeat(64) + ); + + let block: Block = serde_json::from_str(&compact_wire_json).unwrap(); + let canonical_json_len = serde_json::to_vec(&block).unwrap().len(); + + assert_eq!(block.finalizer_mode, FinalizerMode::Ticket); + assert_eq!(block.finalizer_rank, 0); + assert_eq!(block.burn_bundle_section, BurnBundleSection::default()); + assert_eq!(block.serialized_size_bytes().unwrap(), canonical_json_len); + assert!(canonical_json_len > compact_wire_json.len()); + } } diff --git a/src/domain/profile.rs b/src/domain/profile.rs @@ -22,7 +22,7 @@ pub struct LaunchProfile { impl Default for LaunchProfile { fn default() -> Self { Self { - profile_id: "iuna-devnet-v5".to_string(), + profile_id: "iuna-mainnet-candidate-v1".to_string(), ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, mine_difficulty_bits: MINE_DIFFICULTY_BITS, @@ -87,7 +87,7 @@ mod tests { fn default_launch_profile_matches_protocol_defaults() { let profile = LaunchProfile::default(); - assert_eq!(profile.profile_id, "iuna-devnet-v5"); + assert_eq!(profile.profile_id, "iuna-mainnet-candidate-v1"); assert_eq!( profile.ticket_maturity_delay_heights, DEFAULT_TICKET_MATURITY_DELAY @@ -100,6 +100,10 @@ mod tests { 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); + assert_eq!( + profile.hash(), + "aef51531eaa3a5c5d3ea8a2524ffba029dcb106e4b0a432b57d5ac1f4f8963de" + ); } #[test] diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -7,8 +7,8 @@ pub const MINE_FINALIZER_FEE: Amount = MICRO_IUNA; pub const DEFAULT_MINE_FEE: Amount = MINE_FINALIZER_FEE; pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_IUNA; pub const DEFAULT_FEE_PER_BYTE: Amount = 1; -pub const MAX_BLOCK_BYTES: usize = 100_000; -pub const VDF_TARGET_BLOCK_MS: u64 = 5 * 60 * 1_000; +pub const MAX_BLOCK_BYTES: usize = 1_000_000; +pub const VDF_TARGET_BLOCK_MS: u64 = 10 * 60 * 1_000; pub const RECOVERY_BLOCK_DELAY_MS: u64 = VDF_TARGET_BLOCK_MS * 6; pub const MAX_VDF_ROUNDS: u64 = i64::MAX as u64; pub const MINE_DIFFICULTY_BITS: u32 = 12; @@ -42,3 +42,36 @@ impl TransactionSubmitOutcome { matches!(self, Self::Added) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mainnet_candidate_protocol_parameters_are_frozen() { + assert_eq!(MICRO_IUNA, 1_000_000); + assert_eq!(BLOCK_REWARD, MICRO_IUNA); + assert_eq!(MINE_REWARD, MICRO_IUNA); + assert_eq!(MINE_FINALIZER_FEE, MICRO_IUNA); + assert_eq!(MAX_BLOCK_BYTES, 1_000_000); + assert_eq!(VDF_TARGET_BLOCK_MS, 10 * 60 * 1_000); + assert_eq!(RECOVERY_BLOCK_DELAY_MS, 6 * VDF_TARGET_BLOCK_MS); + assert_eq!(MINE_DIFFICULTY_BITS, 12); + assert_eq!(MINE_ACTIONS_PER_ANCHOR_LIMIT, 2); + assert_eq!(BURN_COMMITTEE_SIZE, 3); + assert_eq!(MAX_BURN_BUNDLE_BYTES, 10_000); + assert_eq!(BURN_LINEAGE_MATURITY_HEIGHTS, 20); + assert_eq!(MAX_PENDING_TRANSACTIONS, 10_000); + assert_eq!(MAX_PENDING_POOL_BYTES, 8 * 1024 * 1024); + assert_eq!(MAX_ORPHAN_TRANSACTIONS, 1_024); + assert_eq!(MAX_BLOCK_TRANSACTIONS, 1_000); + assert_eq!(DEFAULT_TICKET_MATURITY_DELAY, 3); + assert_eq!(DEFAULT_TICKET_EXPIRY_WINDOW, 3); + assert_eq!(MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, 2 * 60 * 1_000); + assert_eq!(BLOCK_MEDIAN_TIME_PAST_WINDOW, 11); + assert_eq!(FORK_FINALITY_DEPTH, 6); + assert_eq!(PUBLIC_KEY_BYTES, 32); + assert_eq!(HASH_BYTES, 32); + assert_eq!(SIGNATURE_BYTES, 64); + } +} diff --git a/src/domain/validation.rs b/src/domain/validation.rs @@ -148,9 +148,10 @@ pub(super) fn compact_len(mut value: u128) -> usize { #[cfg(test)] mod tests { use super::{ - compact_len, validate_address, validate_hash, validate_protocol_id, validate_signature, - validate_stratum_header, + canonical_transaction_size_bytes, compact_len, validate_address, validate_hash, + validate_protocol_id, validate_signature, validate_stratum_header, }; + use crate::domain::{OutPoint, Transaction, TxInput, TxOutput}; #[test] fn validators_accept_expected_protocol_lengths() { @@ -179,4 +180,41 @@ mod tests { assert_eq!(compact_len(16_383), 2); assert_eq!(compact_len(16_384), 3); } + + #[test] + fn transaction_economic_size_uses_binary_ids_and_compact_integers() { + let input = TxInput { + outpoint: OutPoint { + txid: "a".repeat(64), + index: 128, + }, + owner: "b".repeat(64), + signature: "c".repeat(128), + }; + let output = TxOutput { + address: "d".repeat(64), + amount: 128, + }; + let tx = Transaction::Transfer { + inputs: vec![input], + outputs: vec![output], + fee: 16_384, + signature: "e".repeat(128), + }; + + assert_eq!( + canonical_transaction_size_bytes(&tx), + 1 // transaction kind + + 1 // input count + + 32 // txid, counted as bytes rather than hex chars + + 2 // output index varint + + 32 // owner public key bytes + + 1 // output count + + 32 // recipient public key bytes + + 2 // amount varint + + 3 // fee varint + + 64 // signature bytes + ); + assert!(tx.serialized_size_bytes().unwrap() > canonical_transaction_size_bytes(&tx)); + } } diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -474,7 +474,7 @@ fn vdf_measurement_extrapolates_to_target() { Duration::from_secs(1), Duration::from_millis(VDF_TARGET_BLOCK_MS), ), - 3_000_000 + 6_000_000 ); assert_eq!( extrapolate_vdf_rounds( @@ -482,7 +482,7 @@ fn vdf_measurement_extrapolates_to_target() { Duration::from_secs(0), Duration::from_millis(VDF_TARGET_BLOCK_MS), ), - 3_000_000_000_000_000 + 6_000_000_000_000_000 ); }