iuna

iuna

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

commit 5130a26ecff4739d345c17b0a4168edc6307c28c
parent 68bbea72bb4c1f7e78201622069fad8c2816b487
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat, 12 Sep 2026 22:35:18 +0200

feat(p2p): advertise quantum migration capabilities

Diffstat:
Mdocs/quantum-migration.md | 17+++++++++++++++++
Msrc/adapters/p2p/fetch.rs | 5++++-
Msrc/adapters/p2p/handshake.rs | 8++++++--
Msrc/adapters/p2p/tests.rs | 15+++++++++++++++
Msrc/app.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/app/gossip.rs | 19+++++++++++++++++--
Msrc/app/types.rs | 20++++++++++++++++++++
7 files changed, 147 insertions(+), 8 deletions(-)

diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -81,6 +81,23 @@ boundary tests with maximum-size hybrid authorizations. All stages must be rehearsed across the height boundary with old and new nodes, snapshot restore, fork recovery, mempool rebroadcast, compact-store reload, and lightweight-wallet signing. +## Release sequence + +Application, transport, and consensus versions move independently: + +1. A protocol-v2 application release advertises read capabilities in the optional `capabilities` + field. Old nodes ignore the field and an omitted field means no advertised capabilities. +2. A later application release ships dormant transaction-v2 and hybrid verification code. It does + not choose an activation height. +3. Only after deployment coverage is measured does another release announce a future activation + height and protocol-v3 transition. The feature must not be introduced and activated in the same + release. +4. Wallet defaults may change after activation without another consensus version. Refusing new + legacy outputs, changing the VDF, or removing Ed25519 each requires its own later activation. + +Capability names are sorted, unique, lowercase ASCII tokens. A hello may advertise at most 16 +tokens of at most 64 bytes each. These limits are enforced before the handshake is accepted. + ## Other trust boundaries - P2P node IDs need versioned, algorithm-tagged proofs independent of wallet activation. diff --git a/src/adapters/p2p/fetch.rs b/src/adapters/p2p/fetch.rs @@ -9,7 +9,7 @@ use tokio::{ use crate::{ app::{ ChainBootstrap, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, ProtocolHello, now_ms, - validate_network_genesis, + protocol_capabilities, validate_network_genesis, validate_protocol_capabilities, }, domain::{Block, ChainSnapshot, LaunchProfile, Ledger, verify_vdf}, }; @@ -53,6 +53,7 @@ async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> { NETWORK_ID ); } + validate_protocol_capabilities(&hello.capabilities)?; Ok(PeerStatus::with_time( hello.height, hello.tip_hash, @@ -98,6 +99,7 @@ pub async fn fetch_snapshot_with_announcement( NETWORK_ID ); } + validate_protocol_capabilities(&hello.capabilities)?; } GossipEnvelope::PeerStatus { .. } => {} other => anyhow::bail!("join peer {peer} sent {other:?} instead of peer status"), @@ -145,6 +147,7 @@ fn join_client_hello() -> GossipEnvelope { let setup = Ledger::new(BTreeMap::new(), 1); GossipEnvelope::Hello(ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: protocol_capabilities(), network_id: NETWORK_ID.to_string(), genesis_hash: setup.genesis_hash().to_string(), listen_addr: None, diff --git a/src/adapters/p2p/handshake.rs b/src/adapters/p2p/handshake.rs @@ -19,7 +19,7 @@ use super::{ use crate::{ app::{ GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, PeerDirection, ProtocolHello, - debug_logging_enabled, now_ms, + debug_logging_enabled, now_ms, validate_protocol_capabilities, }, domain::Ledger, }; @@ -106,6 +106,7 @@ async fn process_hello_inner( PROTOCOL_VERSION ); } + validate_protocol_capabilities(&hello.capabilities)?; if hello.network_id != NETWORK_ID { anyhow::bail!( "wrong network {}; expected {}", @@ -367,7 +368,10 @@ async fn advertised_peer_hello_is_compatible( network: &GossipNetwork, hello: &ProtocolHello, ) -> bool { - if hello.protocol_version != PROTOCOL_VERSION || hello.network_id != NETWORK_ID { + if hello.protocol_version != PROTOCOL_VERSION + || hello.network_id != NETWORK_ID + || validate_protocol_capabilities(&hello.capabilities).is_err() + { return false; } let (local_genesis, local_accepts_remote_genesis) = { diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs @@ -988,6 +988,7 @@ async fn hello_rejects_wrong_network_or_genesis_without_banning() { let wrong_network = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: "other-network".to_string(), genesis_hash: network .inner @@ -1018,6 +1019,7 @@ async fn hello_rejects_wrong_network_or_genesis_without_banning() { let wrong_genesis = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: "not-local-genesis".to_string(), listen_addr: Some("127.0.0.1:9545".to_string()), @@ -1041,6 +1043,7 @@ async fn hello_rejects_wrong_network_or_genesis_without_banning() { let wrong_protocol = ProtocolHello { protocol_version: PROTOCOL_VERSION + 1, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: network .inner @@ -1095,6 +1098,7 @@ async fn hello_records_remote_clock_observation() { let remote_time_ms = crate::app::now_ms().saturating_add(60_000); let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: network .inner @@ -1154,6 +1158,7 @@ async fn hello_remembers_advertised_address_after_signed_session_and_dialback() let remote_node_id = super::new_node_id(); let remote_addr = spawn_hello_server(ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), listen_addr: None, @@ -1170,6 +1175,7 @@ async fn hello_remembers_advertised_address_after_signed_session_and_dialback() let mut reader = super::LimitedLineReader::new(reader); let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), listen_addr: Some(remote_addr.to_string()), @@ -1233,6 +1239,7 @@ async fn hello_ignores_advertised_address_when_connected_peer_cannot_sign_claime let attacker_node_id = super::new_node_id(); let remote_addr = spawn_hello_server(ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), listen_addr: None, @@ -1249,6 +1256,7 @@ async fn hello_ignores_advertised_address_when_connected_peer_cannot_sign_claime let mut reader = super::LimitedLineReader::new(reader); let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), listen_addr: Some(remote_addr.to_string()), @@ -1305,6 +1313,7 @@ async fn dialback_rejects_address_that_signs_with_different_node_id() { let claimed_node_id = super::new_node_id(); let remote_addr = spawn_hello_server(ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), listen_addr: None, @@ -1425,6 +1434,7 @@ async fn setup_placeholder_rejects_bootstrap_with_unpinned_candidate_genesis() { let remote_bootstrap = remote_node.chain_bootstrap(); let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: remote_genesis.clone(), listen_addr: Some("142.132.164.59:9444".to_string()), @@ -1494,6 +1504,7 @@ async fn real_node_accepts_setup_placeholder_peer_without_requesting_its_chain() let setup_ledger = Ledger::new(BTreeMap::new(), 1); let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: setup_ledger.genesis_hash().to_string(), listen_addr: Some("127.0.0.1:9545".to_string()), @@ -1571,6 +1582,7 @@ async fn hello_ignores_private_advertised_listen_address() { let status = node.lock().await.ledger().status(); let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), listen_addr: Some("10.42.1.1:12138".to_string()), @@ -1617,6 +1629,7 @@ async fn hello_ignores_loopback_alias_for_unspecified_self() { }; let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: network .inner @@ -1674,6 +1687,7 @@ async fn hello_removes_outbound_peer_that_announces_self_address() { }; let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: network .inner @@ -1730,6 +1744,7 @@ async fn hello_removes_outbound_peer_with_same_node_id() { }; let hello = ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: Vec::new(), network_id: NETWORK_ID.to_string(), genesis_hash: network .inner diff --git a/src/app.rs b/src/app.rs @@ -41,6 +41,10 @@ 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 = 2; +pub const MAX_PROTOCOL_CAPABILITIES: usize = 16; +pub const MAX_PROTOCOL_CAPABILITY_BYTES: usize = 64; +pub const CAPABILITY_ADDRESS_V1_READ: &str = "address-v1-read"; +pub const CAPABILITY_SIGNATURE_SCHEMES_V1: &str = "signature-schemes-v1"; pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate"; pub const MAINNET_CANDIDATE_GENESIS_HASH: &str = "3d677cd7ced1c04d3a276cbee7ea38076e34ac65f18a2c9b8286a4872d986a9a"; @@ -59,12 +63,43 @@ const BURN_BUNDLE_COLLECTION_MS: u64 = crate::domain::VDF_TARGET_BLOCK_MS / 20; const MIN_AUTO_BLOCK_ANCHOR_BURN_AMOUNT: Amount = 1; static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); +pub fn protocol_capabilities() -> Vec<String> { + vec![ + CAPABILITY_ADDRESS_V1_READ.to_string(), + CAPABILITY_SIGNATURE_SCHEMES_V1.to_string(), + ] +} + +pub fn validate_protocol_capabilities(capabilities: &[String]) -> Result<()> { + if capabilities.len() > MAX_PROTOCOL_CAPABILITIES { + anyhow::bail!("peer advertises too many protocol capabilities"); + } + let mut previous: Option<&str> = None; + for capability in capabilities { + if capability.is_empty() + || capability.len() > MAX_PROTOCOL_CAPABILITY_BYTES + || !capability + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + anyhow::bail!("peer advertises an invalid protocol capability"); + } + if previous.is_some_and(|previous| previous >= capability.as_str()) { + anyhow::bail!("peer protocol capabilities must be sorted and unique"); + } + previous = Some(capability); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ - 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, + BLOCK_REQUEST_LIMIT, CAPABILITY_ADDRESS_V1_READ, CAPABILITY_SIGNATURE_SCHEMES_V1, + DEFAULT_VDF_ROUNDS, MAINNET_CANDIDATE_GENESIS_HASH, MAINNET_CANDIDATE_NETWORK_ID, + MAINNET_NETWORK_ID, MAX_PROTOCOL_CAPABILITIES, NETWORK_ID, PROTOCOL_VERSION, + TRANSACTION_BATCH_LIMIT, protocol_capabilities, validate_network_genesis, + validate_protocol_capabilities, }; #[test] @@ -89,6 +124,36 @@ mod tests { 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()); } + + #[test] + fn current_protocol_capabilities_are_stable_and_valid() { + let capabilities = protocol_capabilities(); + assert_eq!( + capabilities, + [CAPABILITY_ADDRESS_V1_READ, CAPABILITY_SIGNATURE_SCHEMES_V1] + ); + validate_protocol_capabilities(&capabilities).unwrap(); + validate_protocol_capabilities(&[]).unwrap(); + } + + #[test] + fn malformed_protocol_capabilities_fail_closed() { + assert!(validate_protocol_capabilities(&["UPPERCASE".to_string()]).is_err()); + assert!( + validate_protocol_capabilities(&["duplicate".to_string(), "duplicate".to_string()]) + .is_err() + ); + assert!( + validate_protocol_capabilities(&["z-last".to_string(), "a-first".to_string()]).is_err() + ); + assert!( + validate_protocol_capabilities(&vec![ + "capability".to_string(); + MAX_PROTOCOL_CAPABILITIES + 1 + ]) + .is_err() + ); + } } pub fn validate_network_genesis(profile_id: &str, genesis_hash: &str) -> Result<()> { diff --git a/src/app/gossip.rs b/src/app/gossip.rs @@ -2,7 +2,7 @@ use crate::domain::{Block, ChainSnapshot}; use super::{ BLOCK_REQUEST_LIMIT, ChainBootstrap, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, - ProtocolHello, TRANSACTION_BATCH_LIMIT, now_ms, types::BlockInventory, + ProtocolHello, TRANSACTION_BATCH_LIMIT, now_ms, protocol_capabilities, types::BlockInventory, }; impl NodeCore { @@ -53,6 +53,7 @@ impl NodeCore { pub fn hello(&self, listen_addr: Option<String>, node_id: Option<String>) -> GossipEnvelope { GossipEnvelope::Hello(ProtocolHello { protocol_version: PROTOCOL_VERSION, + capabilities: protocol_capabilities(), network_id: NETWORK_ID.to_string(), genesis_hash: self.ledger.genesis_hash().to_string(), listen_addr, @@ -115,11 +116,25 @@ mod tests { use std::collections::BTreeMap; use crate::{ - app::{BLOCK_REQUEST_LIMIT, BlockInventory, GossipEnvelope, NodeCore}, + app::{ + BLOCK_REQUEST_LIMIT, BlockInventory, GossipEnvelope, NodeCore, protocol_capabilities, + }, domain::{Amount, GenesisBurn, Ledger, MICRO_IUNA, Transaction, Wallet}, }; #[test] + fn hello_advertises_current_protocol_capabilities() { + let wallet = Wallet::from_seed("hello-capabilities"); + let node = NodeCore::from_ledger(wallet, Ledger::new(BTreeMap::new(), 1), 0); + + let GossipEnvelope::Hello(hello) = node.hello(None, None) else { + panic!("hello builder returned another envelope type"); + }; + + assert_eq!(hello.capabilities, protocol_capabilities()); + } + + #[test] fn mempool_gossip_rebroadcasts_public_burns() { let wallet = Wallet::from_seed("mempool-gossip-public-burn"); let mut allocations = BTreeMap::new(); diff --git a/src/app/types.rs b/src/app/types.rs @@ -105,6 +105,8 @@ pub struct ChainBootstrap { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ProtocolHello { pub protocol_version: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec<String>, pub network_id: String, pub genesis_hash: String, pub listen_addr: Option<String>, @@ -122,6 +124,24 @@ pub struct BlockInventory { pub hash: String, } +#[cfg(test)] +mod protocol_hello_tests { + use super::ProtocolHello; + + #[test] + fn legacy_hello_without_capabilities_remains_compatible() { + let json = r#"{"protocol_version":2,"network_id":"test","genesis_hash":"genesis","listen_addr":null,"node_id":null,"height":0,"tip_hash":"tip","time_ms":1}"#; + let hello: ProtocolHello = serde_json::from_str(json).unwrap(); + + assert!(hello.capabilities.is_empty()); + assert!( + !serde_json::to_string(&hello) + .unwrap() + .contains("capabilities") + ); + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct NodeStatus { pub app_version: String,