From 3f3f8d163da0aa580099462d275fb20d690e4378 Mon Sep 17 00:00:00 2001 From: Dimitris Sarlis Date: Tue, 10 Feb 2026 15:22:52 +0000 Subject: [PATCH 1/2] feat: Add invariant check for rental subnets --- rs/registry/canister/src/invariants/subnet.rs | 20 ++- .../canister/src/invariants/subnet/tests.rs | 120 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 rs/registry/canister/src/invariants/subnet/tests.rs diff --git a/rs/registry/canister/src/invariants/subnet.rs b/rs/registry/canister/src/invariants/subnet.rs index 46f084a7cf30..74d17f949c79 100644 --- a/rs/registry/canister/src/invariants/subnet.rs +++ b/rs/registry/canister/src/invariants/subnet.rs @@ -9,7 +9,7 @@ use crate::invariants::common::{ use ic_base_types::{NodeId, PrincipalId}; use ic_nns_common::registry::MAX_NUM_SSH_KEYS; -use ic_protobuf::registry::subnet::v1::{SubnetRecord, SubnetType}; +use ic_protobuf::registry::subnet::v1::{CanisterCyclesCostSchedule, SubnetRecord, SubnetType}; use ic_registry_keys::{SUBNET_RECORD_KEY_PREFIX, make_node_record_key, make_subnet_record_key}; use prost::Message; @@ -20,6 +20,7 @@ use prost::Message; /// * Each subnet contains at least one node /// * There is at least one system subnet /// * Each subnet in the registry occurs in the subnet list and vice versa +/// * Only application subnets can be rented and therefore have a "free" cycles cost schedule pub(crate) fn check_subnet_invariants( snapshot: &RegistrySnapshot, ) -> Result<(), InvariantCheckError> { @@ -91,7 +92,21 @@ pub(crate) fn check_subnet_invariants( if subnet_record.subnet_type == i32::from(SubnetType::System) { system_subnet_count += 1; } + + // Only application subnets can be rented and have a "free" cycles cost schedule. + if subnet_record.subnet_type != i32::from(SubnetType::Application) + && subnet_record.canister_cycles_cost_schedule + == i32::from(CanisterCyclesCostSchedule::Free) + { + return Err(InvariantCheckError { + msg: format!( + "Subnet {subnet_id:} is not an application subnet but has a free cycles cost schedule" + ), + source: None, + }); + } } + // There is at least one system subnet. Note that we disable this invariant for benchmarks, as // the code to set up "invariants compliant" registry mostly depends on "test-only" code, and // it's very difficult to conform canbench benchmarks to test-only code. It's also risky to move @@ -129,3 +144,6 @@ pub(crate) fn get_subnet_records_map( } subnets } + +#[cfg(test)] +mod tests; diff --git a/rs/registry/canister/src/invariants/subnet/tests.rs b/rs/registry/canister/src/invariants/subnet/tests.rs new file mode 100644 index 000000000000..9bcbdf36f229 --- /dev/null +++ b/rs/registry/canister/src/invariants/subnet/tests.rs @@ -0,0 +1,120 @@ +use super::*; + +use crate::invariants::common::RegistrySnapshot; +use ic_base_types::SubnetId; +use ic_protobuf::registry::{ + node::v1::NodeRecord, + subnet::v1::{CanisterCyclesCostSchedule, SubnetListRecord, SubnetRecord, SubnetType}, +}; +use ic_registry_keys::{make_subnet_list_record_key, make_subnet_record_key}; +use ic_test_utilities_types::ids::{node_test_id, subnet_test_id}; + +#[test] +fn only_application_subnets_can_be_free_cycles_cost_schedule() { + let system_subnet_id = subnet_test_id(1); + let test_subnet_id = subnet_test_id(2); + let (mut snapshot, mut test_subnet_record) = + setup_minimal_registry_snapshot_for_check_subnet_invariants( + system_subnet_id, + test_subnet_id, + ); + + // Trivial case. (Never forget the trivial case, because this is an edge + // case, and edge cases is where many mistakes are made.) + check_subnet_invariants(&snapshot).unwrap(); + + // Happy case: a compliant `SubnetRecord`. + test_subnet_record.subnet_type = i32::from(SubnetType::Application); + test_subnet_record.canister_cycles_cost_schedule = i32::from(CanisterCyclesCostSchedule::Free); + snapshot.insert( + make_subnet_record_key(test_subnet_id).into_bytes(), + test_subnet_record.encode_to_vec(), + ); + check_subnet_invariants(&snapshot).unwrap(); + + // System or verified application subnets cannot be on "free" cycles cost schedule. + test_subnet_record.subnet_type = i32::from(SubnetType::System); + snapshot.insert( + make_subnet_record_key(test_subnet_id).into_bytes(), + test_subnet_record.encode_to_vec(), + ); + assert_non_compliant_record( + &snapshot, + "is not an application subnet but has a free cycles cost schedule", + ); + + test_subnet_record.subnet_type = i32::from(SubnetType::VerifiedApplication); + snapshot.insert( + make_subnet_record_key(test_subnet_id).into_bytes(), + test_subnet_record.encode_to_vec(), + ); + assert_non_compliant_record( + &snapshot, + "is not an application subnet but has a free cycles cost schedule", + ); +} + +fn setup_minimal_registry_snapshot_for_check_subnet_invariants( + system_subnet_id: SubnetId, + test_subnet_id: SubnetId, +) -> (RegistrySnapshot, SubnetRecord) { + let mut snapshot = RegistrySnapshot::new(); + + let system_node_id = node_test_id(1); + snapshot.insert( + make_node_record_key(system_node_id.to_owned()).into_bytes(), + NodeRecord::default().encode_to_vec(), + ); + + let subnet_list_record = SubnetListRecord { + subnets: vec![system_subnet_id.get().into_vec()], + }; + snapshot.insert( + make_subnet_list_record_key().into_bytes(), + subnet_list_record.encode_to_vec(), + ); + let subnet_record = SubnetRecord { + membership: vec![system_node_id.get().into_vec()], + subnet_type: i32::from(SubnetType::System), + ..Default::default() + }; + snapshot.insert( + make_subnet_record_key(system_subnet_id).into_bytes(), + subnet_record.encode_to_vec(), + ); + + // Add a test subnet in the subnet list. + let test_node_id = node_test_id(100); + snapshot.insert( + make_node_record_key(test_node_id.to_owned()).into_bytes(), + NodeRecord::default().encode_to_vec(), + ); + let subnet_list_record = SubnetListRecord { + subnets: vec![ + system_subnet_id.get().into_vec(), + test_subnet_id.get().into_vec(), + ], + }; + snapshot.insert( + make_subnet_list_record_key().into_bytes(), + subnet_list_record.encode_to_vec(), + ); + let test_subnet_record = SubnetRecord { + membership: vec![test_node_id.get().to_vec()], + ..Default::default() + }; + snapshot.insert( + make_subnet_record_key(test_subnet_id).into_bytes(), + test_subnet_record.encode_to_vec(), + ); + + (snapshot, test_subnet_record) +} + +fn assert_non_compliant_record(snapshot: &RegistrySnapshot, error_msg: &str) { + let Err(err) = check_subnet_invariants(&snapshot) else { + panic!("Expected Err, but got Ok!"); + }; + let message = err.msg.to_lowercase(); + assert!(message.contains(error_msg)); +} From 53d1e65c9facfe6556bb25e2c94cfd04396d50ba Mon Sep 17 00:00:00 2001 From: Dimitris Sarlis Date: Wed, 11 Feb 2026 10:29:55 +0000 Subject: [PATCH 2/2] Fix clippy --- rs/registry/canister/src/invariants/subnet/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/registry/canister/src/invariants/subnet/tests.rs b/rs/registry/canister/src/invariants/subnet/tests.rs index 9bcbdf36f229..2eb05a9cf5e0 100644 --- a/rs/registry/canister/src/invariants/subnet/tests.rs +++ b/rs/registry/canister/src/invariants/subnet/tests.rs @@ -112,7 +112,7 @@ fn setup_minimal_registry_snapshot_for_check_subnet_invariants( } fn assert_non_compliant_record(snapshot: &RegistrySnapshot, error_msg: &str) { - let Err(err) = check_subnet_invariants(&snapshot) else { + let Err(err) = check_subnet_invariants(snapshot) else { panic!("Expected Err, but got Ok!"); }; let message = err.msg.to_lowercase();