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:
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,